From b58b666259f7c521bb5e42824878f15156ada2cb Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 20:10:25 -0400 Subject: [PATCH 1/2] Fix Control Center groups query and redesign V2 Activity Logs GROUP is a reserved Cosmos SQL keyword, so dotted access such as c.group.group_id returned HTTP 400 and broke the V2 groups inventory, the group detail drawer, the Activity Logs group filter, every Activity Logs search (group.group_name was a search field) and the classic group activity timeline. Group paths are now bracket-quoted (c['group']) and the Cosmos query guard rejects dotted access to reserved keywords across all Control Center SQL. The groups inventory also tolerates malformed legacy group documents. Activity Logs now uses an Azure-portal-style filter pill bar, a slim clickable trend strip and a log table that shows people and workspace names (resolved server-side, cached and batched) instead of raw IDs. People, activity types and workspaces cross-filter in one click; a detail drawer ports V1's per-type details. Search also matches people names and emails. Times show in local time with a remembered UTC toggle. Saved views move to the account (one-time import from this browser). CSV export adds readable name and summary columns. New people and workspace lookup routes back the pill comboboxes. Version 0.261.294. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../functions_control_center_activity.py | 119 ++- ...nctions_control_center_activity_display.py | 780 ++++++++++++++++++ .../functions_control_center_groups.py | 64 +- .../route_backend_control_center.py | 156 +++- application/single_app/route_backend_users.py | 3 + .../controlCenter/ActivityLogsSection.tsx | 527 ++++++------ .../activityLogs/ActivityDetailDrawer.tsx | 169 ++++ .../activityLogs/ActivityFilterBar.tsx | 478 +++++++++++ .../activityLogs/ActivityTable.tsx | 267 ++++++ .../activityLogs/ActivityTrendStrip.tsx | 149 ++++ .../activityLogs/ActivityViewsMenu.tsx | 145 ++++ .../activityLogs/EntityCombobox.tsx | 176 ++++ .../controlCenter/activityLogs/FilterPill.tsx | 225 +++++ .../v2_ui/src/lib/activityLogSavedViews.ts | 99 +++ application/v2_ui/src/lib/activityLogs.ts | 392 +++++++++ application/v2_ui/src/lib/userSettings.ts | 11 + .../explanation/features/V2_CONTROL_CENTER.md | 72 +- ...L_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md | 4 + ...NTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md | 73 ++ docs/guides/v2-control-center.md | 31 +- .../test_route_blueprint_policy_inventory.py | 2 + .../test_support/cosmos_query_guard.py | 29 +- ...test_v2_control_center_activity_display.py | 293 +++++++ ...v2_control_center_activity_logs_queries.py | 112 ++- ..._v2_control_center_activity_logs_routes.py | 182 +++- ...ntrol_center_cosmos_query_compatibility.py | 96 ++- .../test_v2_control_center_groups.py | 31 +- .../test_v2_control_center_activity_logs.py | 505 ++++++++++-- 29 files changed, 4729 insertions(+), 463 deletions(-) create mode 100644 application/single_app/functions_control_center_activity_display.py create mode 100644 application/v2_ui/src/components/controlCenter/activityLogs/ActivityDetailDrawer.tsx create mode 100644 application/v2_ui/src/components/controlCenter/activityLogs/ActivityFilterBar.tsx create mode 100644 application/v2_ui/src/components/controlCenter/activityLogs/ActivityTable.tsx create mode 100644 application/v2_ui/src/components/controlCenter/activityLogs/ActivityTrendStrip.tsx create mode 100644 application/v2_ui/src/components/controlCenter/activityLogs/ActivityViewsMenu.tsx create mode 100644 application/v2_ui/src/components/controlCenter/activityLogs/EntityCombobox.tsx create mode 100644 application/v2_ui/src/components/controlCenter/activityLogs/FilterPill.tsx create mode 100644 application/v2_ui/src/lib/activityLogSavedViews.ts create mode 100644 application/v2_ui/src/lib/activityLogs.ts create mode 100644 docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md create mode 100644 functional_tests/test_v2_control_center_activity_display.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4206520c9..05464869a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.293" +VERSION = "0.261.294" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_control_center_activity.py b/application/single_app/functions_control_center_activity.py index a1d1d1092..71993cfea 100644 --- a/application/single_app/functions_control_center_activity.py +++ b/application/single_app/functions_control_center_activity.py @@ -14,6 +14,11 @@ ACTIVITY_PAGE_MAX = 200 ACTIVITY_SUMMARY_MAX = 5000 ACTIVITY_EXPORT_MAX = 10000 +ACTIVITY_EXPORT_HEADER = ( + "timestamp", "id", "user_id", "activity_type", "workspace_type", "user_name", "user_email", + "activity", "summary", "workspace_id", "workspace_name", "raw_json", +) +ACTIVITY_EXPORT_READABLE_COLUMNS = 6 ACTIVITY_ORDER = " ORDER BY c.timestamp DESC, c.id DESC, c.user_id DESC" ACTIVITY_COMPOSITE_INDEX = [ {"path": "/timestamp", "order": "descending"}, @@ -28,6 +33,44 @@ "group.group_name", "workspace_context.group_id", "workspace_context.public_workspace_id", ) +# Cosmos SQL keywords that cannot be used as dotted property names; the query grammar only +# accepts ALL, FIRST and LAST there. A dotted reserved word (c.group.group_id) is a syntax +# error that rejects the whole query, so those segments are written as c['group']. +COSMOS_RESERVED_WORDS = frozenset({ + "AND", "ARRAY", "AS", "ASC", "BETWEEN", "BY", "DESC", "DISTINCT", "ESCAPE", "EXISTS", + "FALSE", "FROM", "GROUP", "IN", "JOIN", "LEFT", "LIKE", "LIMIT", "NOT", "NULL", "OFFSET", + "OR", "ORDER", "RANK", "RIGHT", "SELECT", "TOP", "TRUE", "UDF", "UNDEFINED", "VALUE", "WHERE", +}) + + +def cosmos_property_path(path, root="c"): + """Return a Cosmos property reference that is valid even when a segment is a keyword.""" + expression = root + for segment in path.split("."): + expression += f"['{segment}']" if segment.upper() in COSMOS_RESERVED_WORDS else f".{segment}" + return expression + + +NESTED_GROUP_ID = cosmos_property_path("group.group_id") +# Where the writers record who acted. Records from approvals, membership and status changes +# often have no top-level user_id, so the person filter and the people search match all of +# them. The display module resolves the table's Person column from the same fields. +ACTIVITY_ACTOR_FIELDS = ( + "user_id", "admin_user_id", "requester_id", "added_by_user_id", "changed_by_user_id", + "changed_by.user_id", "removed_by.user_id", "admin.user_id", "actor.user_id", +) +# Where the writers record a group or public workspace. Status changes and member removals +# nest it (group.group_id, public_workspace.*), approvals and membership audits store it at +# the top level, user agreements store workspace_context._workspace_id, and public +# workspace ownership approvals store only a bare workspace_id. The display module resolves +# the Workspace column from the same locations. +GROUP_REFERENCE_FIELDS = ( + "workspace_context.group_id", "group_id", "group.group_id", "workspace_context.group_workspace_id", +) +PUBLIC_REFERENCE_FIELDS = ( + "workspace_context.public_workspace_id", "public_workspace_id", "public_workspace.public_workspace_id", + "public_workspace.workspace_id", "workspace_id", +) def parse_activity_filters(args, now=None): @@ -60,7 +103,14 @@ def parse_activity_filters(args, now=None): return result -def activity_query_context(filters): +def _either(fields, template): + """OR the same predicate over several property paths, bracket-quoting reserved words.""" + return " OR ".join(template.format(path=cosmos_property_path(field)) for field in fields) + + +def activity_query_context(filters, search_user_ids=()): + """Build the WHERE clause. search_user_ids are people whose name or email matched the + search; they widen the search only and stay outside the cursor's filter scope.""" end_exclusive = (date.fromisoformat(filters["end_date"]) + timedelta(days=1)).isoformat() clauses = ["IS_STRING(c.timestamp)", "IS_STRING(c.id)", "(IS_STRING(c.user_id) OR IS_NULL(c.user_id) OR NOT IS_DEFINED(c.user_id))", @@ -74,21 +124,22 @@ def add(clause, name, value): parameters.append({"name": name, "value": value}) add("ARRAY_CONTAINS(@types, c.activity_type)", "@types", filters["activity_types"]) - add("(c.user_id = @user OR c.changed_by.user_id = @user OR c.admin_user_id = @user)", - "@user", filters["user_id"]) + add(f"({_either(ACTIVITY_ACTOR_FIELDS, '{path} = @user')})", "@user", filters["user_id"]) workspace_type = filters["workspace_type"] - if workspace_type == "public": - add("c.workspace_type IN ('public', 'public_workspace')", "@workspace_type", workspace_type) - else: - add("c.workspace_type = @workspace_type", "@workspace_type", workspace_type) group_id = filters["group_id"] or (filters["workspace_id"] if workspace_type == "group" else "") public_id = filters["public_workspace_id"] or (filters["workspace_id"] if workspace_type == "public" else "") - add("(c.workspace_context.group_id = @group OR c.group_id = @group OR c.group.group_id = @group)", - "@group", group_id) - add("(c.workspace_context.public_workspace_id = @public OR c.public_workspace_id = @public)", - "@public", public_id) + # A specific group or public workspace matches every record that references it, however + # its writer recorded the workspace type; a type on its own matches the same references. if workspace_type == "personal": + add("c.workspace_type = @workspace_type", "@workspace_type", workspace_type) add("c.user_id = @personal", "@personal", filters["workspace_id"]) + elif workspace_type == "group" and not group_id: + clauses.append(f"(c.workspace_type = 'group' OR {_either(GROUP_REFERENCE_FIELDS, 'IS_STRING({path})')})") + elif workspace_type == "public" and not public_id: + clauses.append("(c.workspace_type IN ('public', 'public_workspace') OR " + f"{_either(PUBLIC_REFERENCE_FIELDS, 'IS_STRING({path})')})") + add(f"({_either(GROUP_REFERENCE_FIELDS, '{path} = @group')})", "@group", group_id) + add(f"({_either(PUBLIC_REFERENCE_FIELDS, '{path} = @public')})", "@public", public_id) add("c.token_type = @token_type", "@token_type", filters["token_type"]) add("c.usage.model = @model", "@model", filters["model"]) if filters["status"] == "failed": @@ -99,7 +150,10 @@ def add(clause, name, value): add("(c.status = @status OR c.status_change.new_status = @status OR c.document.status = @status)", "@status", filters["status"]) if filters["search"]: - search = " OR ".join(f"CONTAINS(c.{field}, @search, true)" for field in ACTIVITY_SEARCH_FIELDS) + search = _either(ACTIVITY_SEARCH_FIELDS, "CONTAINS({path}, @search, true)") + if search_user_ids: + search += " OR " + _either(ACTIVITY_ACTOR_FIELDS, "ARRAY_CONTAINS(@search_people, {path})") + parameters.append({"name": "@search_people", "value": list(search_user_ids)}) add(f"({search})", "@search", filters["search"]) return " AND ".join(clauses), parameters @@ -138,9 +192,9 @@ def decode_activity_cursor(value, filters): raise ValueError("Invalid activity cursor") from ex -def query_activity_rows(container, filters, limit, cursor=None, snapshot=None, projection="*"): +def query_activity_rows(container, filters, limit, cursor=None, snapshot=None, projection="*", search_user_ids=()): """Three-part key: IDs are only unique inside a user partition, not globally.""" - where, parameters = activity_query_context(filters) + where, parameters = activity_query_context(filters, search_user_ids) snapshot = cursor["snapshot"] if cursor else snapshot or datetime.now(timezone.utc).isoformat() # Compare calendar instants from both legacy naive-UTC and aware-UTC writers. # The cursor keeps the stored timestamp verbatim so lexical ordering and seeking agree. @@ -170,11 +224,13 @@ def query_activity_rows(container, filters, limit, cursor=None, snapshot=None, p return rows, snapshot -def activity_page(container, filters, page_size=50, cursor_value=None): +def activity_page(container, filters, page_size=50, cursor_value=None, search_user_ids=()): if not 1 <= page_size <= ACTIVITY_PAGE_MAX: raise ValueError("Invalid page size") cursor = decode_activity_cursor(cursor_value, filters) if cursor_value else None - rows, snapshot = query_activity_rows(container, filters, page_size + 1, cursor=cursor) + rows, snapshot = query_activity_rows( + container, filters, page_size + 1, cursor=cursor, search_user_ids=search_user_ids, + ) more = len(rows) > page_size records = rows[:page_size] return { @@ -183,10 +239,11 @@ def activity_page(container, filters, page_size=50, cursor_value=None): } -def activity_summary(container, filters): +def activity_summary(container, filters, search_user_ids=()): """Bounded projection, not COUNT/GROUP BY scans. Disclose sampling in the contract.""" rows, snapshot = query_activity_rows( container, filters, ACTIVITY_SUMMARY_MAX + 1, projection="c.timestamp, c.id, c.user_id, c.activity_type", + search_user_ids=search_user_ids, ) truncated = len(rows) > ACTIVITY_SUMMARY_MAX rows = rows[:ACTIVITY_SUMMARY_MAX] @@ -216,10 +273,16 @@ def activity_csv_cell(value): return text -def activity_csv_stream(container, filters, first_rows, snapshot): - """Stream page-sized reads with a final status row when the export hits its cap.""" +def activity_csv_stream(container, filters, first_rows, snapshot, search_user_ids=(), present_rows=None): + """Stream page-sized reads with a final status row when the export hits its cap. + + The first five columns and the trailing raw JSON keep their 0.261.284 positions. The + readable columns between them come from present_rows(rows), which returns one + (user_name, user_email, activity, summary, workspace_id, workspace_name) per row. + """ output = StringIO() writer = csv.writer(output) + blank = ("",) * ACTIVITY_EXPORT_READABLE_COLUMNS def line(values): output.seek(0) @@ -227,16 +290,24 @@ def line(values): writer.writerow([activity_csv_cell(value) for value in values]) return output.getvalue() - yield line(("timestamp", "id", "user_id", "activity_type", "workspace_type", "raw_json")) + yield line(ACTIVITY_EXPORT_HEADER) rows = first_rows count = 0 while rows: - for row in rows[:ACTIVITY_EXPORT_MAX - count]: + batch = rows[:ACTIVITY_EXPORT_MAX - count] + readable = list(present_rows(batch)) if present_rows else [] + if len(readable) != len(batch): + readable = [blank] * len(batch) + for row, columns in zip(batch, readable): yield line((row.get("timestamp"), row.get("id"), row.get("user_id"), row.get("activity_type"), - row.get("workspace_type"), json.dumps(row, ensure_ascii=False))) + row.get("workspace_type"), *columns, json.dumps(row, ensure_ascii=False))) count += 1 if count >= ACTIVITY_EXPORT_MAX: - yield line(("", "", "", "export_limit_reached", "", f"Export capped at {ACTIVITY_EXPORT_MAX} rows; narrow the filters.")) + yield line(("", "", "", "export_limit_reached", "", *blank, + f"Export capped at {ACTIVITY_EXPORT_MAX} rows; narrow the filters.")) return cursor = decode_activity_cursor(encode_activity_cursor(rows[-1], filters, snapshot), filters) - rows, _ = query_activity_rows(container, filters, min(ACTIVITY_PAGE_MAX, ACTIVITY_EXPORT_MAX - count), cursor) + rows, _ = query_activity_rows( + container, filters, min(ACTIVITY_PAGE_MAX, ACTIVITY_EXPORT_MAX - count), cursor, + search_user_ids=search_user_ids, + ) diff --git a/application/single_app/functions_control_center_activity_display.py b/application/single_app/functions_control_center_activity_display.py new file mode 100644 index 000000000..a1399a790 --- /dev/null +++ b/application/single_app/functions_control_center_activity_display.py @@ -0,0 +1,780 @@ +# functions_control_center_activity_display.py +"""Readable labels, summaries and names for Control Center activity records. + +The Activity Logs table, its detail drawer and its CSV export share this presentation, so +an activity reads the same everywhere. The module has no Flask or Azure bootstrap +dependency: storage handles are passed in by the route, which keeps the presentation +testable with fakes. Names come from SimpleChat's own user_settings, groups and +public_workspaces documents (the same source the classic Control Center used), never from +Microsoft Graph, and are returned only to Control Center administrators. +""" + +import time +from collections import OrderedDict + + +ACTIVITY_LOOKUP_LIMIT = 10 +ACTIVITY_SEARCH_PEOPLE_MAX = 25 +ACTIVITY_SEARCH_PEOPLE_MIN_LENGTH = 2 +ACTIVITY_NAME_BATCH = 100 +ACTIVITY_TEXT_LIMIT = 500 +ACTIVITY_FACT_LIMIT = 16 +SYSTEM_ACTOR_IDS = frozenset({"system", "unknown"}) + +ACTIVITY_CATEGORY_LABELS = { + "sign_in": "Sign-in and consent", + "chat": "Chat and conversations", + "documents": "Documents", + "tokens": "Token usage", + "groups": "Groups", + "public_workspaces": "Public workspaces", + "administration": "Approvals and administration", + "agents": "Agents, actions and workflows", + "data": "Data and sync", + "other": "Other", +} + +# Labels for the activity types SimpleChat writes. Unlisted types are humanized, so a new +# writer still reads sensibly before it is added here. +ACTIVITY_TYPES = { + "user_login": ("User login", "sign_in"), + "terms_of_use_accepted": ("Terms of use accepted", "sign_in"), + "terms_of_use_declined": ("Terms of use declined", "sign_in"), + "user_agreement_accepted": ("User agreement accepted", "sign_in"), + "web_search_consent_acceptance": ("Web search consent accepted", "sign_in"), + "chat_activity": ("Chat activity", "chat"), + "conversation_creation": ("Conversation created", "chat"), + "conversation_deletion": ("Conversation deleted", "chat"), + "conversation_archival": ("Conversation archived", "chat"), + "document_creation": ("Document created", "documents"), + "document_deletion": ("Document deleted", "documents"), + "document_metadata_update": ("Document metadata updated", "documents"), + "document_upload": ("Document uploaded", "documents"), + "token_usage": ("Token usage", "tokens"), + "group_status_change": ("Group status changed", "groups"), + "group_member_deleted": ("Group member removed", "groups"), + "add_member_directly": ("Group member added", "groups"), + "admin_add_member_csv": ("Group members imported", "groups"), + "update_member_role": ("Group member role changed", "groups"), + "public_workspace_status_change": ("Public workspace status changed", "public_workspaces"), + "add_workspace_member_directly": ("Workspace manager added", "public_workspaces"), + "admin_add_workspace_member_csv": ("Workspace managers imported", "public_workspaces"), + "public_add_member_directly": ("Public workspace member added", "public_workspaces"), + "public_member_removed": ("Public workspace member removed", "public_workspaces"), + "public_membership_requested": ("Public workspace access requested", "public_workspaces"), + "public_membership_request_canceled": ("Public workspace request canceled", "public_workspaces"), + "public_update_member_role": ("Public workspace role changed", "public_workspaces"), + "admin_take_ownership_approved": ("Group ownership taken (approved)", "administration"), + "transfer_ownership_approved": ("Group ownership transferred (approved)", "administration"), + "delete_group_approved": ("Group deleted (approved)", "administration"), + "delete_all_documents_approved": ("Group documents deleted (approved)", "administration"), + "delete_all_user_documents_approved": ("User documents deleted (approved)", "administration"), + "admin_take_workspace_ownership_approved": ("Workspace ownership taken (approved)", "administration"), + "transfer_workspace_ownership_approved": ("Workspace ownership transferred (approved)", "administration"), + "delete_workspace_documents_approved": ("Workspace documents deleted (approved)", "administration"), + "delete_workspace_approved": ("Workspace deleted (approved)", "administration"), + "admin_action": ("Admin action", "administration"), + "governance": ("Governance change", "administration"), + "retention_policy_force_push": ("Retention policy pushed", "administration"), + "admin_feedback_email_submission": ("Feedback email sent", "administration"), + "admin_release_notifications_registration": ("Release notifications registered", "administration"), + "user_support_feedback_email_submission": ("Support feedback sent", "other"), + "agent_creation": ("Agent created", "agents"), + "agent_update": ("Agent updated", "agents"), + "agent_deletion": ("Agent deleted", "agents"), + "agent_run": ("Agent used", "agents"), + "agent_template_submission": ("Agent template submitted", "agents"), + "agent_template_approval": ("Agent template approved", "agents"), + "agent_template_rejection": ("Agent template rejected", "agents"), + "agent_template_deletion": ("Agent template deleted", "agents"), + "action_creation": ("Action created", "agents"), + "action_update": ("Action updated", "agents"), + "action_deletion": ("Action deleted", "agents"), + "workflow_creation": ("Workflow created", "agents"), + "workflow_update": ("Workflow updated", "agents"), + "workflow_deletion": ("Workflow deleted", "agents"), + "workflow_run": ("Workflow run", "agents"), + "file_sync": ("File sync", "data"), + "data_management": ("Data management", "data"), + "index_auto_fix": ("Search index fields added", "data"), +} + +# Where each writer records who acted. Records written by approvals, membership changes and +# status changes often have no top-level user_id, so the first present field wins. +ACTOR_ID_PATHS = ( + ("user_id",), ("admin_user_id",), ("requester_id",), ("added_by_user_id",), + ("changed_by_user_id",), ("changed_by", "user_id"), ("removed_by", "user_id"), + ("admin", "user_id"), ("actor", "user_id"), +) +ACTOR_EMAIL_PATHS = ( + ("admin_email",), ("requester_email",), ("added_by_email",), ("changed_by_email",), + ("changed_by", "email"), ("removed_by", "email"), ("admin", "email"), ("actor", "email"), +) +GROUP_ID_PATHS = ( + ("workspace_context", "group_id"), ("group_id",), ("group", "group_id"), + ("workspace_context", "group_workspace_id"), +) +GROUP_NAME_PATHS = (("group", "group_name"), ("group_name",), ("workspace_context", "workspace_name")) +PUBLIC_ID_PATHS = ( + ("workspace_context", "public_workspace_id"), ("public_workspace_id",), + ("public_workspace", "public_workspace_id"), ("public_workspace", "workspace_id"), +) +PUBLIC_NAME_PATHS = ( + ("public_workspace", "workspace_name"), ("public_workspace", "public_workspace_name"), + ("workspace_context", "public_workspace_name"), ("public_workspace_name",), ("workspace_name",), + ("workspace_context", "workspace_name"), +) +WORKSPACE_TYPES = { + "personal": "personal", "group": "group", "public": "public", "public_workspace": "public", + "admin": "admin", "global": "global", +} + + +def _value(record, *path): + value = record + for part in path: + if not isinstance(value, dict): + return None + value = value.get(part) + return value + + +def _text(value): + """A trimmed display string; containers and booleans are not display text.""" + if isinstance(value, bool) or value is None or isinstance(value, (dict, list)): + return "" + text = str(value).strip() + return text[:ACTIVITY_TEXT_LIMIT] + + +def _first_text(record, paths): + for path in paths: + text = _text(_value(record, *path)) + if text: + return text + return "" + + +def _number(value): + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value + + +def _count(value): + number = _number(value) + if number is None: + return "" + return f"{int(number):,}" if float(number).is_integer() else f"{number:,.2f}" + + +def _bytes(value): + number = _number(value) + if number is None or number < 0: + return "" + for unit in ("bytes", "KB", "MB", "GB"): + if number < 1024 or unit == "GB": + return f"{int(number):,} bytes" if unit == "bytes" else f"{number:,.1f} {unit}" + number /= 1024 + return "" + + +def humanize(value): + """Turn a stored identifier such as document_action_chat into 'Document action chat'.""" + text = _text(value).replace("_", " ").replace("-", " ") + text = " ".join(text.split()) + return text[:1].upper() + text[1:] if text else "" + + +def _yes_no(value): + return ("Yes" if value else "No") if isinstance(value, bool) else "" + + +def activity_label(activity_type): + known = ACTIVITY_TYPES.get(activity_type) if isinstance(activity_type, str) else None + return known[0] if known else (humanize(activity_type) or "Unknown activity") + + +def activity_category(activity_type): + known = ACTIVITY_TYPES.get(activity_type) if isinstance(activity_type, str) else None + return known[1] if known else "other" + + +def activity_type_catalog(): + """Every labelled activity type, in category order, for the Activity filter.""" + order = list(ACTIVITY_CATEGORY_LABELS) + entries = [ + {"activity_type": key, "label": label, "category": category, + "category_label": ACTIVITY_CATEGORY_LABELS[category]} + for key, (label, category) in ACTIVITY_TYPES.items() + ] + return sorted(entries, key=lambda item: (order.index(item["category"]), item["label"].casefold())) + + +def label_activity_facets(facets): + """Add the label and category to summary facets without changing their counts.""" + return [ + {**facet, "label": activity_label(facet.get("activity_type")), + "category": activity_category(facet.get("activity_type"))} + for facet in facets + ] + + +def activity_actor(record): + """Who acted: a user ID when one was recorded, otherwise an email, otherwise the system.""" + actor_id = "" + for path in ACTOR_ID_PATHS: + candidate = _value(record, *path) + if isinstance(candidate, str) and candidate.strip(): + actor_id = candidate.strip() + break + email = _first_text(record, ACTOR_EMAIL_PATHS) + if actor_id.casefold() in SYSTEM_ACTOR_IDS: + return {"id": "", "email": email, "kind": "user" if email else "system"} + return {"id": actor_id, "email": email, "kind": "user" if actor_id or email else "system"} + + +def activity_workspace(record): + """Where the activity happened, using every location the writers record. + + These locations mirror GROUP_REFERENCE_FIELDS and PUBLIC_REFERENCE_FIELDS in the query + module, so a workspace shown in a row is one its filter matches. + """ + stored_type = WORKSPACE_TYPES.get(_text(record.get("workspace_type")).casefold(), "") + group_id = _first_text(record, GROUP_ID_PATHS) + public_id = _first_text(record, PUBLIC_ID_PATHS) + # Public workspace ownership approvals record only a bare workspace_id. + if not group_id and not public_id and stored_type in ("", "public"): + public_id = _text(record.get("workspace_id")) + if group_id and stored_type != "public": + return {"type": "group", "id": group_id, "recorded_name": _first_text(record, GROUP_NAME_PATHS)} + if public_id and stored_type != "group": + return {"type": "public", "id": public_id, "recorded_name": _first_text(record, PUBLIC_NAME_PATHS)} + return {"type": stored_type, "id": "", "recorded_name": ""} + + +def activity_status(record): + """'failed' when the record says it failed or errored, else None.""" + for path in (("status",), ("document", "status"), ("additional_context", "status"), ("run", "status")): + text = _text(_value(record, *path)).casefold() + if "fail" in text or "error" in text: + return "failed" + if _text(_value(record, "additional_context", "error")) or _text(_value(record, "run", "error")): + return "failed" + return None + + +class _Facts: + def __init__(self): + self.items = [] + self.labels = set() + + def add(self, label, value): + text = value if isinstance(value, str) else _text(value) + if text and label not in self.labels and len(self.items) < ACTIVITY_FACT_LIMIT: + self.items.append({"label": label, "value": text}) + self.labels.add(label) + + +def _member(record): + return (_text(record.get("member_name")) or _text(_value(record, "removed_member", "name")) + or _text(_value(record, "added_member", "name")) or _text(record.get("member_email")) + or _text(_value(record, "removed_member", "email")) or _text(record.get("target_user_name")) + or "a member") + + +def _entity_name(record): + return (_text(_value(record, "entity", "display_name")) or _text(_value(record, "entity", "name")) + or _text(_value(record, "agent", "display_name")) or _text(_value(record, "agent", "name"))) + + +def _workspace_label(record): + workspace = activity_workspace(record) + return workspace["recorded_name"] or workspace["id"] + + +def _describe_chat(record, facts): + context = record.get("additional_context") if isinstance(record.get("additional_context"), dict) else {} + source_labels = {"document_action_chat": "Document action", "collaboration_chat": "Multi-user collaboration", + "standard_chat": "Standard chat"} + message = humanize(record.get("message_type")) or "Message" + source = (humanize(context.get("document_action_type")) + or source_labels.get(_text(context.get("conversation_source"))) + or humanize(context.get("conversation_source"))) + place = humanize(record.get("chat_context") or record.get("workspace_type")) + summary = " · ".join(part for part in (message, source, place) if part) + length = _number(record.get("message_length")) + detail = f"{_count(length)} characters" if length else "" + facts.add("Message type", message) + facts.add("Source", source) + facts.add("Context", place) + facts.add("Conversation ID", record.get("conversation_id")) + facts.add("Characters", _count(length)) + facts.add("Document search", _yes_no(record.get("has_document_search"))) + facts.add("Image generation", _yes_no(record.get("has_image_generation"))) + facts.add("Agent", context.get("agent_name")) + facts.add("Reasoning effort", context.get("reasoning_effort")) + facts.add("Visibility", humanize(context.get("visibility_mode"))) + return summary, detail + + +def _describe_document(record, facts, activity_type): + document = record.get("document") if isinstance(record.get("document"), dict) else {} + file_name = _text(document.get("file_name")) or "Unknown file" + file_type = _text(document.get("file_type")) + pages = _number(document.get("page_count")) + updated = record.get("updated_fields") if isinstance(record.get("updated_fields"), dict) else {} + if activity_type == "document_metadata_update": + detail = f"Updated: {', '.join(sorted(str(key) for key in updated))}" if updated else "" + else: + detail = " · ".join(part for part in ( + file_type.lstrip(".").upper(), _bytes(document.get("file_size_bytes")), + f"{_count(pages)} pages" if pages else "", + ) if part) + facts.add("File name", file_name) + facts.add("File type", file_type) + facts.add("Size", _bytes(document.get("file_size_bytes"))) + facts.add("Pages", _count(pages)) + facts.add("Processing status", document.get("status")) + facts.add("Updated fields", ", ".join(sorted(str(key) for key in updated))) + facts.add("Embedding tokens", _count(_value(record, "embedding_usage", "total_tokens"))) + facts.add("Embedding model", _value(record, "embedding_usage", "model_deployment_name")) + facts.add("Document ID", document.get("document_id")) + return file_name, detail + + +def _describe_tokens(record, facts): + usage = record.get("usage") if isinstance(record.get("usage"), dict) else {} + total = _count(usage.get("total_tokens")) or "0" + model = _text(usage.get("model")) + token_type = humanize(record.get("token_type")) + summary = " · ".join(part for part in (f"{total} tokens", model) if part) + prompt, completion = _count(usage.get("prompt_tokens")), _count(usage.get("completion_tokens")) + file_name = _text(_value(record, "embedding_details", "file_name")) + detail = " · ".join(part for part in ( + token_type, f"prompt {prompt}" if prompt else "", f"completion {completion}" if completion else "", + file_name, + ) if part) + facts.add("Token type", token_type) + facts.add("Total tokens", total) + facts.add("Prompt tokens", prompt) + facts.add("Completion tokens", completion) + facts.add("Model", model) + facts.add("File", file_name) + facts.add("Conversation ID", _value(record, "chat_details", "conversation_id")) + facts.add("Message ID", _value(record, "chat_details", "message_id")) + facts.add("Document ID", _value(record, "embedding_details", "document_id")) + return summary, detail + + +def _describe_status_change(record, facts): + change = record.get("status_change") if isinstance(record.get("status_change"), dict) else {} + old, new = _text(change.get("old_status")) or "unknown", _text(change.get("new_status")) or "unknown" + reason = _text(change.get("reason")) + facts.add("Previous status", old) + facts.add("New status", new) + facts.add("Reason", reason) + facts.add("Changed by", _value(record, "changed_by", "email")) + return f"{humanize(old)} → {humanize(new)}", reason or _workspace_label(record) + + +def _describe_membership(record, facts, activity_type): + member = _member(record) + target = _workspace_label(record) + old_role, new_role = _text(record.get("old_role")), _text(record.get("new_role")) + role = _text(record.get("member_role")) + if activity_type in ("update_member_role", "public_update_member_role"): + summary = f"{member}: {old_role or 'unknown'} → {new_role or 'unknown'}" + elif activity_type in ("group_member_deleted", "public_member_removed"): + summary = f"Removed {member}" + elif activity_type in ("public_membership_requested", "public_membership_request_canceled"): + summary = _text(record.get("description")) or activity_label(activity_type) + else: + summary = f"Added {member}" + detail = " · ".join(part for part in (f"Role: {role}" if role else "", target) if part) + facts.add("Member", member if member != "a member" else "") + facts.add("Member email", record.get("member_email") or _value(record, "removed_member", "email")) + facts.add("Role", role) + facts.add("Previous role", old_role) + facts.add("New role", new_role) + facts.add("Removed by", _value(record, "removed_by", "email")) + return summary, detail + + +def _describe_approval(record, facts): + summary = _text(record.get("description")) or activity_label(record.get("activity_type")) + requester = _text(record.get("requester_email")) or _text(record.get("admin_email")) + approver = _text(record.get("approver_email")) + detail = " · ".join(part for part in ( + f"Requested by {requester}" if requester else "", f"approved by {approver}" if approver else "", + ) if part) + facts.add("Requested by", requester) + facts.add("Approved by", approver) + facts.add("Previous owner", record.get("old_owner_email")) + facts.add("New owner", record.get("new_owner_email")) + facts.add("Documents deleted", _count(record.get("documents_deleted"))) + facts.add("Target user", record.get("target_user_name") or record.get("target_user_email")) + facts.add("Approval ID", record.get("approval_id")) + return summary, detail + + +def _describe_file_sync(record, facts): + context = record.get("workspace_context") if isinstance(record.get("workspace_context"), dict) else {} + extra = record.get("additional_context") if isinstance(record.get("additional_context"), dict) else {} + counts = extra.get("counts") if isinstance(extra.get("counts"), dict) else {} + source = _text(context.get("source_name")) or _text(extra.get("source_name")) or "Unknown source" + action = humanize(record.get("action")) or "Sync event" + detail = " · ".join( + f"{humanize(key)} {_count(counts.get(key))}" + for key in ("scanned", "queued", "unchanged", "skipped", "deleted", "failed") + if _count(counts.get(key)) + ) + facts.add("Action", action) + facts.add("Source", source) + facts.add("Scope", humanize(context.get("scope_type") or record.get("workspace_type"))) + facts.add("Run ID", extra.get("run_id")) + for key in ("scanned", "queued", "unchanged", "skipped", "deleted", "failed"): + facts.add(humanize(key), _count(counts.get(key))) + facts.add("Error", extra.get("error")) + facts.add("Source ID", context.get("source_id")) + return f"{action} · {source}", detail + + +def _describe_data_management(record, facts): + extra = record.get("additional_context") if isinstance(record.get("additional_context"), dict) else {} + context = record.get("workspace_context") if isinstance(record.get("workspace_context"), dict) else {} + action = humanize(record.get("action")) or "Data management event" + operation = humanize(extra.get("operation") or context.get("operation")) + status = humanize(extra.get("status")) + job = _text(extra.get("job_id") or context.get("job_id")) + facts.add("Action", action) + facts.add("Operation", operation) + facts.add("Status", status) + facts.add("Backup type", humanize(extra.get("backup_type") or context.get("backup_type"))) + facts.add("Job ID", job) + return " · ".join(part for part in (action, operation) if part), " · ".join( + part for part in (status, f"Job {job}" if job else "") if part + ) + + +def _describe_agents(record, facts, activity_type): + name = _entity_name(record) or "Unnamed" + run = record.get("run") if isinstance(record.get("run"), dict) else {} + scope = humanize(record.get("workspace_type")) + status = humanize(run.get("status") or _value(record, "entity", "status")) + detail = " · ".join(part for part in ( + scope, status, humanize(run.get("trigger_source")), _text(record.get("model_deployment_name")), + ) if part) + facts.add("Name", name) + facts.add("Operation", humanize(record.get("operation"))) + facts.add("Scope", scope) + facts.add("Status", status) + facts.add("Trigger", humanize(run.get("trigger_source"))) + facts.add("Model", record.get("model_deployment_name")) + facts.add("Error", run.get("error")) + facts.add("Review reason", record.get("review_reason")) + facts.add("Run ID", run.get("id")) + facts.add("Conversation ID", run.get("conversation_id") or record.get("conversation_id")) + return name, detail + + +def _describe_governance(record, facts): + context = record.get("workspace_context") if isinstance(record.get("workspace_context"), dict) else {} + action = humanize(record.get("action")) or "Governance change" + scope, target = humanize(context.get("scope")), _text(context.get("target_id")) + facts.add("Action", action) + facts.add("Scope", scope) + facts.add("Target", target) + return action, " · ".join(part for part in (scope, target) if part) + + +def describe_activity(record): + """Return label, category, one-line summary, secondary detail, facts and failure state.""" + record = record if isinstance(record, dict) else {} + activity_type = _text(record.get("activity_type")) + facts = _Facts() + summary, detail = "", "" + if activity_type == "user_login": + method = _text(record.get("login_method")) or _text(_value(record, "details", "login_method")) + summary, detail = "Signed in", f"Method: {method}" if method else "" + facts.add("Login method", method) + elif activity_type == "chat_activity": + summary, detail = _describe_chat(record, facts) + elif activity_type in ("conversation_creation", "conversation_deletion", "conversation_archival"): + conversation = record.get("conversation") if isinstance(record.get("conversation"), dict) else {} + summary = _text(conversation.get("title")) or "Untitled conversation" + tags = conversation.get("tags") if isinstance(conversation.get("tags"), list) else [] + detail = ", ".join(_text(tag) for tag in tags if _text(tag)) + facts.add("Title", summary) + facts.add("Conversation ID", conversation.get("conversation_id")) + facts.add("Tags", detail) + elif activity_type in ("document_creation", "document_deletion", "document_metadata_update"): + summary, detail = _describe_document(record, facts, activity_type) + elif activity_type == "token_usage": + summary, detail = _describe_tokens(record, facts) + elif activity_type in ("group_status_change", "public_workspace_status_change"): + summary, detail = _describe_status_change(record, facts) + elif activity_type in ( + "group_member_deleted", "add_member_directly", "admin_add_member_csv", "update_member_role", + "add_workspace_member_directly", "admin_add_workspace_member_csv", "public_add_member_directly", + "public_member_removed", "public_update_member_role", "public_membership_requested", + "public_membership_request_canceled", + ): + summary, detail = _describe_membership(record, facts, activity_type) + elif activity_type.endswith("_approved"): + summary, detail = _describe_approval(record, facts) + elif activity_type == "file_sync": + summary, detail = _describe_file_sync(record, facts) + elif activity_type == "data_management": + summary, detail = _describe_data_management(record, facts) + elif activity_type == "governance": + summary, detail = _describe_governance(record, facts) + elif activity_category(activity_type) == "agents": + summary, detail = _describe_agents(record, facts, activity_type) + description = _text(record.get("description")) + if not summary: + summary = description or humanize(record.get("action")) or activity_label(activity_type) + facts.add("Description", description if description != summary else "") + facts.add("Action", humanize(record.get("action"))) + return { + "activity_type": activity_type, + "label": activity_label(activity_type), + "category": activity_category(activity_type), + "summary": summary, + "detail": detail if detail != summary else "", + "facts": facts.items, + "status": activity_status(record), + } + + +class ActivityNameCache: + """A small TTL cache for display names, shared across pages and exports.""" + + def __init__(self, ttl_seconds=300, max_entries=5000, clock=time.monotonic): + self.ttl_seconds = ttl_seconds + self.max_entries = max_entries + self.clock = clock + self.entries = OrderedDict() + + def get(self, kind, key): + entry = self.entries.get((kind, key)) + if not entry: + return False, None + expires_at, value = entry + if expires_at <= self.clock(): + self.entries.pop((kind, key), None) + return False, None + return True, value + + def set(self, kind, key, value): + self.entries[(kind, key)] = (self.clock() + self.ttl_seconds, value) + self.entries.move_to_end((kind, key)) + while len(self.entries) > self.max_entries: + self.entries.popitem(last=False) + + +def _lookup(container, kind, ids, query, project, cache): + """Resolve IDs in batches with one parameterized query each; cache misses as None.""" + found = {} + pending = [] + for item_id in dict.fromkeys(item for item in ids if isinstance(item, str) and item): + hit, value = cache.get(kind, item_id) if cache else (False, None) + if hit: + found[item_id] = value + else: + pending.append(item_id) + for start in range(0, len(pending), ACTIVITY_NAME_BATCH): + batch = pending[start:start + ACTIVITY_NAME_BATCH] + rows = container.query_items( + query=query, parameters=[{"name": "@ids", "value": batch}], enable_cross_partition_query=True, + ) + resolved = {row["id"]: project(row) for row in rows if isinstance(row, dict) and row.get("id") in batch} + for item_id in batch: + found[item_id] = resolved.get(item_id) + if cache: + cache.set(kind, item_id, found[item_id]) + return found + + +def collect_activity_name_ids(records, filters=None): + """The user, group and public workspace IDs a page of records and its filters mention.""" + people, groups, public = [], [], [] + for record in records: + if not isinstance(record, dict): + continue + actor = activity_actor(record) + if actor["id"]: + people.append(actor["id"]) + workspace = activity_workspace(record) + if workspace["type"] == "group" and workspace["id"]: + groups.append(workspace["id"]) + elif workspace["type"] == "public" and workspace["id"]: + public.append(workspace["id"]) + if filters: + reference = activity_filter_workspace(filters) + if filters.get("user_id"): + people.append(filters["user_id"]) + if reference["type"] == "group": + groups.append(reference["id"]) + elif reference["type"] == "public": + public.append(reference["id"]) + return people, groups, public + + +def resolve_activity_names(records, filters=None, *, user_container, groups_container, public_container, cache=None): + """Batched name lookups for one page: at most one query per kind for uncached IDs.""" + people, groups, public = collect_activity_name_ids(records, filters) + return { + "people": _lookup( + user_container, "person", people, + "SELECT c.id, c.display_name, c.email FROM c WHERE ARRAY_CONTAINS(@ids, c.id)", + lambda row: {"display_name": _text(row.get("display_name")), "email": _text(row.get("email"))}, + cache, + ), + "groups": _lookup( + groups_container, "group", groups, + "SELECT c.id, c.name FROM c WHERE ARRAY_CONTAINS(@ids, c.id)", + lambda row: _text(row.get("name")), cache, + ), + "public_workspaces": _lookup( + public_container, "public", public, + "SELECT c.id, c.name FROM c WHERE ARRAY_CONTAINS(@ids, c.id)", + lambda row: _text(row.get("name")), cache, + ), + } + + +def empty_activity_names(): + return {"people": {}, "groups": {}, "public_workspaces": {}} + + +def _person_view(actor_id, email, names): + person = names["people"].get(actor_id) if actor_id else None + return { + "id": actor_id, + "name": (person or {}).get("display_name", ""), + "email": (person or {}).get("email") or email, + "kind": "user" if actor_id or email else "system", + "resolved": bool(person), + } + + +def _workspace_view(workspace, names): + lookup = {"group": names["groups"], "public": names["public_workspaces"]}.get(workspace["type"], {}) + current = lookup.get(workspace["id"]) if workspace["id"] else None + return { + "type": workspace["type"], + "id": workspace["id"], + "name": current or workspace["recorded_name"], + "resolved": bool(current), + } + + +def present_activity_record(record, names): + """The presentation the table, drawer and export share for one record.""" + view = describe_activity(record) + actor = activity_actor(record if isinstance(record, dict) else {}) + view["actor"] = _person_view(actor["id"], actor["email"], names) + view["workspace"] = _workspace_view(activity_workspace(record if isinstance(record, dict) else {}), names) + return view + + +def present_activity_rows(records, names): + return [present_activity_record(record, names) for record in records] + + +def activity_filter_workspace(filters): + """The specific workspace a filter set names, using the same precedence as the query.""" + workspace_type = filters.get("workspace_type") or "" + group_id = filters.get("group_id") or (filters.get("workspace_id") if workspace_type == "group" else "") + public_id = filters.get("public_workspace_id") or (filters.get("workspace_id") if workspace_type == "public" else "") + if group_id: + return {"type": "group", "id": group_id} + if public_id: + return {"type": "public", "id": public_id} + return {"type": workspace_type, "id": ""} + + +def activity_filter_labels(filters, names): + """Names for the person and workspace filters, so the toolbar never shows a bare ID.""" + labels = {} + if filters.get("user_id"): + person = _person_view(filters["user_id"], "", names) + labels["person"] = {key: person[key] for key in ("id", "name", "email", "resolved")} + reference = activity_filter_workspace(filters) + if reference["id"]: + labels["workspace"] = _workspace_view({**reference, "recorded_name": ""}, names) + return labels + + +def activity_csv_columns(view): + """Readable export columns from a presentation, in the export header's order.""" + summary = view["summary"] + (f" ({view['detail']})" if view.get("detail") else "") + return ( + view["actor"]["name"], view["actor"]["email"], view["label"], summary, + view["workspace"]["id"], view["workspace"]["name"], + ) + + +def _search_term(term): + term = term.strip() if isinstance(term, str) else "" + return term if len(term) >= ACTIVITY_SEARCH_PEOPLE_MIN_LENGTH else "" + + +def _ranked(rows, term, *fields): + folded = term.casefold() + + def rank(row): + values = [_text(row.get(field)).casefold() for field in fields] + if row.get("id") == term or folded in values: + return 0 + if any(value.startswith(folded) for value in values): + return 1 + return 2 + return sorted(rows, key=lambda row: (rank(row), _text(row.get(fields[0])).casefold(), _text(row.get("id")))) + + +def search_activity_people(container, term, limit=ACTIVITY_LOOKUP_LIMIT): + """SimpleChat users whose name or email contains the term, or whose ID is the term.""" + term = _search_term(term) + if not term: + return [] + rows = container.query_items( + query=("SELECT TOP @limit c.id, c.display_name, c.email FROM c WHERE c.id = @term " + "OR CONTAINS(c.display_name, @term, true) OR CONTAINS(c.email, @term, true)"), + parameters=[{"name": "@limit", "value": limit}, {"name": "@term", "value": term}], + enable_cross_partition_query=True, + ) + people = [ + {"id": row["id"], "display_name": _text(row.get("display_name")), "email": _text(row.get("email"))} + for row in rows if isinstance(row, dict) and isinstance(row.get("id"), str) + ] + return _ranked(people, term, "display_name", "email")[:limit] + + +def search_activity_people_ids(container, term): + """IDs for search expansion: up to ACTIVITY_SEARCH_PEOPLE_MAX, with a truncation flag.""" + if not _search_term(term): + return [], False + people = search_activity_people(container, term, ACTIVITY_SEARCH_PEOPLE_MAX + 1) + return [person["id"] for person in people[:ACTIVITY_SEARCH_PEOPLE_MAX]], len(people) > ACTIVITY_SEARCH_PEOPLE_MAX + + +def search_activity_workspaces(groups_container, public_container, term, limit=ACTIVITY_LOOKUP_LIMIT): + """Groups and public workspaces whose name contains the term, or whose ID is the term.""" + term = _search_term(term) + if not term: + return [] + found = [] + for workspace_type, container in (("group", groups_container), ("public", public_container)): + rows = container.query_items( + query="SELECT TOP @limit c.id, c.name FROM c WHERE c.id = @term OR CONTAINS(c.name, @term, true)", + parameters=[{"name": "@limit", "value": limit}, {"name": "@term", "value": term}], + enable_cross_partition_query=True, + ) + matches = [ + {"type": workspace_type, "id": row["id"], "name": _text(row.get("name"))} + for row in rows if isinstance(row, dict) and isinstance(row.get("id"), str) + ] + found.extend(_ranked(matches, term, "name")[:limit]) + return found diff --git a/application/single_app/functions_control_center_groups.py b/application/single_app/functions_control_center_groups.py index 460df6e81..a35fd4d17 100644 --- a/application/single_app/functions_control_center_groups.py +++ b/application/single_app/functions_control_center_groups.py @@ -15,6 +15,9 @@ "search", "status", "status_filter", "owner", "members_min", "members_max", "has_documents", "created_from", "created_to", "activity_from", "activity_to", } +# GROUP is a reserved word in Cosmos SQL. A dotted c.group.group_id is a syntax error that +# makes Cosmos reject the whole query, so the nested writer shape is read with brackets. +NESTED_GROUP_ID = "c['group']['group_id']" class GroupRequestError(ValueError): @@ -76,37 +79,60 @@ def parse_group_filters(values): return filters +def _group_text(value): + """Legacy documents can hold a non-string name or email; never let one break sorting.""" + if value is None: + return "" + return value if isinstance(value, str) else str(value) + + +def _group_object(value): + return value if isinstance(value, dict) else {} + + +def _group_ids(value): + return [item for item in value if isinstance(item, str)] if isinstance(value, list) else [] + + def group_members(group): - """Project actual membership roles, including an owner omitted from legacy users.""" - owner = group.get("owner") or {} - users = list(group.get("users") or []) - if owner.get("id") and not any(user.get("userId") == owner["id"] for user in users): - users.append({"userId": owner["id"], **{k: owner.get(k, "") for k in ("email", "displayName")}}) + """Project actual membership roles, including an owner omitted from legacy users. + + Malformed legacy entries are skipped so one bad document cannot fail the inventory. + """ + owner = _group_object(group.get("owner")) + raw_users = group.get("users") + users = [user for user in raw_users if isinstance(user, dict)] if isinstance(raw_users, list) else [] + admins = _group_ids(group.get("admins")) + managers = _group_ids(group.get("documentManagers")) + owner_id = owner.get("id") if isinstance(owner.get("id"), str) else None + if owner_id and not any(user.get("userId") == owner_id for user in users): + users.append({"userId": owner_id, **{k: owner.get(k, "") for k in ("email", "displayName")}}) members = {} for user in users: user_id = user.get("userId") - if not user_id: + if not isinstance(user_id, str) or not user_id: continue - role = ("Owner" if user_id == owner.get("id") else - "Admin" if user_id in (group.get("admins") or []) else - "DocumentManager" if user_id in (group.get("documentManagers") or []) else "User") + role = ("Owner" if user_id == owner_id else + "Admin" if user_id in admins else + "DocumentManager" if user_id in managers else "User") members[user_id] = { - "id": user_id, "display_name": user.get("displayName", ""), - "email": user.get("email", ""), "role": role, + "id": user_id, "display_name": _group_text(user.get("displayName")), + "email": _group_text(user.get("email")), "role": role, } return list(members.values()) def group_row(group, documents, tokens, last_activity): - owner = group.get("owner") or {} - metrics = group.get("metrics") or {} + owner = _group_object(group.get("owner")) + metrics = _group_object(group.get("metrics")) return { "id": group["id"], - "name": group.get("name") or "", - "description": group.get("description") or "", + "name": _group_text(group.get("name")), + "description": _group_text(group.get("description")), "owner": { - "id": owner.get("id"), "email": owner.get("email") or "", - "display_name": owner.get("displayName") or owner.get("display_name") or "", + "id": owner.get("id") if isinstance(owner.get("id"), str) else None, + "email": _group_text(owner.get("email")), + "display_name": _group_text(owner.get("displayName") or owner.get("display_name")), }, "status": group.get("status") if group.get("status") in GROUP_STATUSES else "active", "members": len(group_members(group)), @@ -146,8 +172,8 @@ def load_group_inventory(groups_container, documents_container, activity_contain # records and includes admin CSV and approval events that use top-level group_id. group_expression = ( "IIF(IS_STRING(c.group_id) AND c.group_id != '', c.group_id, " - "IIF(IS_STRING(c.group.group_id) AND c.group.group_id != '', " - "c.group.group_id, c.workspace_context.group_id))" + f"IIF(IS_STRING({NESTED_GROUP_ID}) AND {NESTED_GROUP_ID} != '', " + f"{NESTED_GROUP_ID}, c.workspace_context.group_id))" ) activity_times = {} for row in activity_container.query_items( diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index c0bc7194c..5762d5c13 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -27,6 +27,20 @@ parse_activity_filters, query_activity_rows, ) +from functions_control_center_activity_display import ( + ACTIVITY_LOOKUP_LIMIT, + ActivityNameCache, + activity_csv_columns, + activity_filter_labels, + activity_type_catalog, + empty_activity_names, + label_activity_facets, + present_activity_rows, + resolve_activity_names, + search_activity_people, + search_activity_people_ids, + search_activity_workspaces, +) from functions_settings import * from functions_logging import * from functions_activity_logging import * @@ -97,6 +111,52 @@ ) _control_center_dashboard_cache = {} _control_center_group_snapshot_cache = {} +_control_center_activity_name_cache = ActivityNameCache() + + +def _activity_search_people(filters): + """People whose name or email matches the search, so search finds what they did. + + The page, summary and export each widen the same search, so a term's matches are cached + briefly with the names. A lookup failure narrows the search back to the stored fields. + """ + term = filters["search"].strip().casefold() + if not term: + return [], False + hit, cached = _control_center_activity_name_cache.get("search", term) + if hit: + return cached + try: + result = search_activity_people_ids(cosmos_user_settings_container, filters["search"]) + except Exception as ex: + log_event('[CONTROL_CENTER] Activity people search failed; searching stored fields only.', + extra={'error_type': type(ex).__name__, 'status_code': getattr(ex, 'status_code', None)}, + level=logging.WARNING) + return [], False + _control_center_activity_name_cache.set("search", term, result) + return result + + +def _activity_names(records, filters=None): + """Display names for a page; a lookup failure leaves IDs on screen instead of failing it.""" + try: + return resolve_activity_names( + records, filters, + user_container=cosmos_user_settings_container, + groups_container=cosmos_groups_container, + public_container=cosmos_public_workspaces_container, + cache=_control_center_activity_name_cache, + ) + except Exception as ex: + log_event('[CONTROL_CENTER] Activity name lookup failed; showing IDs.', + extra={'error_type': type(ex).__name__, 'status_code': getattr(ex, 'status_code', None)}, + level=logging.WARNING) + return empty_activity_names() + + +def _activity_export_columns(rows): + names = _activity_names(rows) + return [activity_csv_columns(view) for view in present_activity_rows(rows, names)] def _control_center_group_inventory(force_refresh=False): @@ -3539,19 +3599,30 @@ def register_route_backend_control_center(bp): @login_required @control_center_required('activity_logs') def api_v2_control_center_activity_logs(): + """A page of activity with readable presentation and names for the active filters.""" try: filters = parse_activity_filters(request.args) + page_size = int(request.args.get('page_size', 50)) + except ValueError: + return jsonify({'error': 'Invalid activity filters, page size, or cursor. Use a UTC date range of up to 366 days.'}), 400 + search_ids, search_truncated = _activity_search_people(filters) + try: payload = activity_page( cosmos_activity_logs_container, filters, - page_size=int(request.args.get('page_size', 50)), cursor_value=request.args.get('cursor'), + page_size=page_size, cursor_value=request.args.get('cursor'), search_user_ids=search_ids, ) - return jsonify(payload) except ValueError: return jsonify({'error': 'Invalid activity filters, page size, or cursor. Use a UTC date range of up to 366 days.'}), 400 except Exception as ex: log_event('[CONTROL_CENTER] Activity feed query failed.', - extra={'error_type': type(ex).__name__}, level=logging.ERROR) + extra={'error_type': type(ex).__name__, 'status_code': getattr(ex, 'status_code', None)}, + level=logging.ERROR) return jsonify({'error': 'Unable to load activity logs. Check the activity-log composite index in App Maintenance, then retry.'}), 500 + names = _activity_names(payload['items'], filters) + payload['presentation'] = present_activity_rows(payload['items'], names) + payload['filter_labels'] = activity_filter_labels(filters, names) + payload['search_people'] = {'matched': len(search_ids), 'truncated': search_truncated} + return jsonify(payload) @bp.route('/api/v2/control-center/activity-logs/summary', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -3560,13 +3631,19 @@ def api_v2_control_center_activity_logs(): def api_v2_control_center_activity_summary(): try: filters = parse_activity_filters(request.args) - return jsonify(activity_summary(cosmos_activity_logs_container, filters)) except ValueError: return jsonify({'error': 'Invalid activity filters. Use a UTC date range of up to 366 days.'}), 400 + search_ids, _ = _activity_search_people(filters) + try: + summary = activity_summary(cosmos_activity_logs_container, filters, search_user_ids=search_ids) except Exception as ex: log_event('[CONTROL_CENTER] Activity summary query failed.', - extra={'error_type': type(ex).__name__}, level=logging.ERROR) + extra={'error_type': type(ex).__name__, 'status_code': getattr(ex, 'status_code', None)}, + level=logging.ERROR) return jsonify({'error': 'Unable to load the activity summary. Check App Maintenance indexing status and retry.'}), 500 + summary['facets'] = label_activity_facets(summary['facets']) + summary['type_catalog'] = activity_type_catalog() + return jsonify(summary) @bp.route('/api/v2/control-center/activity-logs/export.csv', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -3575,17 +3652,25 @@ def api_v2_control_center_activity_summary(): def api_v2_control_center_activity_export(): try: filters = parse_activity_filters(request.args) - rows, snapshot = query_activity_rows(cosmos_activity_logs_container, filters, ACTIVITY_PAGE_MAX) except ValueError: return jsonify({'error': 'Invalid activity export filters. Use a UTC date range of up to 366 days.'}), 400 + search_ids, _ = _activity_search_people(filters) + try: + rows, snapshot = query_activity_rows( + cosmos_activity_logs_container, filters, ACTIVITY_PAGE_MAX, search_user_ids=search_ids, + ) except Exception as ex: log_event('[CONTROL_CENTER] Activity export query failed.', - extra={'error_type': type(ex).__name__}, level=logging.ERROR) + extra={'error_type': type(ex).__name__, 'status_code': getattr(ex, 'status_code', None)}, + level=logging.ERROR) return jsonify({'error': 'Unable to export activity logs. Check App Maintenance indexing status and retry.'}), 500 def generate(): try: - yield from activity_csv_stream(cosmos_activity_logs_container, filters, rows, snapshot) + yield from activity_csv_stream( + cosmos_activity_logs_container, filters, rows, snapshot, + search_user_ids=search_ids, present_rows=_activity_export_columns, + ) except Exception as ex: log_event('[CONTROL_CENTER] Activity export stream interrupted.', extra={'error_type': type(ex).__name__}, level=logging.ERROR) @@ -3597,6 +3682,42 @@ def generate(): response.headers['X-Export-Row-Limit'] = '10000' return response + @bp.route('/api/v2/control-center/activity-logs/people', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('activity_logs') + def api_v2_control_center_activity_people(): + """SimpleChat users matching a name, email or ID, for the Activity Logs person filter.""" + term = (request.args.get('q') or '').strip() + if len(term) > 200: + return jsonify({'error': 'Search text is too long.'}), 400 + try: + return jsonify({'people': search_activity_people(cosmos_user_settings_container, term, ACTIVITY_LOOKUP_LIMIT)}) + except Exception as ex: + log_event('[CONTROL_CENTER] Activity people lookup failed.', + extra={'error_type': type(ex).__name__, 'status_code': getattr(ex, 'status_code', None)}, + level=logging.ERROR) + return jsonify({'error': 'Unable to search people. Retry.'}), 500 + + @bp.route('/api/v2/control-center/activity-logs/workspaces', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('activity_logs') + def api_v2_control_center_activity_workspaces(): + """Groups and public workspaces matching a name or ID, for the workspace filter.""" + term = (request.args.get('q') or '').strip() + if len(term) > 200: + return jsonify({'error': 'Search text is too long.'}), 400 + try: + return jsonify({'workspaces': search_activity_workspaces( + cosmos_groups_container, cosmos_public_workspaces_container, term, ACTIVITY_LOOKUP_LIMIT, + )}) + except Exception as ex: + log_event('[CONTROL_CENTER] Activity workspace lookup failed.', + extra={'error_type': type(ex).__name__, 'status_code': getattr(ex, 'status_code', None)}, + level=logging.ERROR) + return jsonify({'error': 'Unable to search workspaces. Retry.'}), 500 + # User Management APIs @bp.route('/api/admin/control-center/users', methods=['GET']) @@ -4395,7 +4516,8 @@ def api_v2_control_center_groups(): return jsonify({"error": "Invalid group filters."}), 400 except Exception as ex: log_event("[CONTROL_CENTER] V2 group list failed.", - extra={"error_type": type(ex).__name__}, level=logging.ERROR) + extra={"error_type": type(ex).__name__, "status_code": getattr(ex, "status_code", None)}, + level=logging.ERROR) return jsonify({"error": "Unable to retrieve groups."}), 500 @bp.route('/api/v2/control-center/groups/', methods=['GET']) @@ -4418,10 +4540,10 @@ def api_v2_control_center_group_detail(group_id): activity = list(cosmos_activity_logs_container.query_items( query=( "SELECT TOP 20 c.id, c.activity_type, c.timestamp, c.description, " - "c.user_id, c.admin_user_id, c.admin_email, c.group, c.workspace_context, " + "c.user_id, c.admin_user_id, c.admin_email, c['group'], c.workspace_context, " "c.status_change, c.added_member, c.removed_member, c.member_email, " "c.member_name, c.member_role, c.document, c.usage, c.token_type " - "FROM c WHERE c.group_id = @group_id OR c.group.group_id = @group_id " + "FROM c WHERE c.group_id = @group_id OR c['group']['group_id'] = @group_id " "OR c.workspace_context.group_id = @group_id ORDER BY c.timestamp DESC" ), parameters=[{"name": "@group_id", "value": group_id}], @@ -4458,7 +4580,9 @@ def api_v2_control_center_group_detail(group_id): return jsonify({"error": "Group not found."}), 404 except Exception as ex: log_event("[CONTROL_CENTER] V2 group detail failed.", - extra={"group_id": group_id, "error_type": type(ex).__name__}, level=logging.ERROR) + extra={"group_id": group_id, "error_type": type(ex).__name__, + "status_code": getattr(ex, "status_code", None)}, + level=logging.ERROR) return jsonify({"error": "Unable to retrieve group details."}), 500 @bp.route('/api/v2/control-center/groups/bulk-status', methods=['POST']) @@ -5230,17 +5354,19 @@ def api_admin_get_group_activity(group_id): pass # Build queries - use two separate queries to avoid nested property access issues - # Query 1: Activities with c.group.group_id (member/status changes) + # Query 1: Activities with the nested group.group_id (member/status changes) # Query 2: Activities with c.workspace_context.group_id (document operations) time_filter = "AND c.timestamp >= @cutoff_date" if cutoff_date else "" - # Query 1: Member and status activities (all activity types with c.group.group_id) + # Query 1: Member and status activities (all activity types with a nested group.group_id). + # GROUP is a reserved word in Cosmos SQL, so the property is read as c['group']; + # the dotted form is a syntax error that made this query fail on every call. # Use SELECT * to get complete raw documents for modal display query1 = f""" SELECT * FROM c - WHERE c.group.group_id = @group_id + WHERE c['group']['group_id'] = @group_id {time_filter} """ diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index f49953c52..2f032c303 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -569,6 +569,9 @@ def user_settings(): 'v2UserSettingsRailCollapsed', # Whether the V2 Control Center section rail is collapsed to icons. 'v2ControlCenterRailCollapsed', + # V2 Control Center Activity Logs: named filter combinations (saved views) + # and display preferences (time zone, density, trend strip visibility). + 'v2ActivityLogSavedViews', 'v2ActivityLogPrefs', # V2 document explorer: how the workspace documents list is presented # (view mode, visible columns, page size, details pane) and the saved # filter combinations pinned in its navigation rail. diff --git a/application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx b/application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx index 46d0e9bf8..276eac02c 100644 --- a/application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx +++ b/application/v2_ui/src/components/controlCenter/ActivityLogsSection.tsx @@ -1,122 +1,63 @@ // ActivityLogsSection.tsx -// THESIS: Follow evidence from a bounded activity feed, without implying unbounded totals. -// OWN-WORLD: Inherit the Control Center's semantic surfaces, blue accent, and workhorse type. -// STORY: Filter a UTC window, inspect its distribution, then open the record and its related entity. -// FIRST VIEWPORT: Filters precede the histogram and facet chips; the chronological table owns the workspace. -// FORM: Operate; a local extension of the established management pane, with keyboard-safe details. +// THESIS: The toolbar is the investigation's state, and the log is visible in the first viewport. +// OWN-WORLD: Inherit the Control Center's semantic glass surfaces, blue accent, and workhorse type. +// STORY: Narrow by time, activity, person or workspace with pills; read who did what, where; open a record for its evidence. +// FIRST VIEWPORT: Header with views and export, one row of filter pills, a slim trend strip, then log rows. +// FORM: Operate; Azure-portal filter pills over a dense table where every name is a cross-filter. -import { useEffect, useMemo, useState, type FormEvent } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Download, RefreshCw } from 'lucide-react'; -import { clsx } from 'clsx'; import { api, apiUrl, CREDENTIALS_MODE } from '../../lib/apiClient'; +import { + apiParams, filterParams, humanize, isDefaultFilters, parseActivityLogPrefs, presetDates, readFilters, todayUtc, + type ActivityFilterLabels, type ActivityFilters, type ActivityLogPrefs, type ActivityPage, type ActivitySummary, + type ActivityTypeOption, type ActivityWorkspaceRef, type WorkspaceType, +} from '../../lib/activityLogs'; +import { + legacyActivityViewsKey, mergeImportedActivityViews, parseActivitySavedViews, readLegacyActivityViews, + removeActivitySavedView, renameActivitySavedView, upsertActivitySavedView, +} from '../../lib/activityLogSavedViews'; import { useBootstrapStore } from '../../stores/bootstrapStore'; -import { cartesianOptions, StatsChart } from '../settings/StatsChart'; -import { GlassButton, GlassPanel } from '../ui/primitives'; -import { DetailDrawer } from './ControlCenterPrimitives'; +import { toast } from '../../stores/toastStore'; +import { useUserSettingsStore } from '../../stores/userSettingsStore'; +import { GlassButton } from '../ui/primitives'; +import { ActivityDetailDrawer } from './activityLogs/ActivityDetailDrawer'; +import { ActivityFilterBar, type FilterChangeOptions } from './activityLogs/ActivityFilterBar'; +import { ActivityTable, fallbackPresentation } from './activityLogs/ActivityTable'; +import { ActivityTrendStrip } from './activityLogs/ActivityTrendStrip'; +import { ActivityViewsMenu } from './activityLogs/ActivityViewsMenu'; -type ActivityRecord = { - id: string; - timestamp: string; - activity_type?: unknown; - user_id?: unknown; - workspace_type?: unknown; - [key: string]: unknown; -}; -type ActivityPage = { items: ActivityRecord[]; next_cursor: string | null; snapshot: string }; -type ActivitySummary = { - facets: { activity_type: string; count: number }[]; - histogram: { date: string; count: number }[]; - bucket_days: number; - sample_size: number; - sample_limit: number; - truncated: boolean; -}; -type SavedView = { name: string; query: string }; -const FILTER_KEYS = ['start_date', 'end_date', 'user_id', 'workspace_type', 'workspace_id', 'group_id', - 'public_workspace_id', 'search', 'token_type', 'model', 'status'] as const; -type FilterKey = typeof FILTER_KEYS[number]; -type Filters = Record & { activity_type: string[] }; -const FIELD_CLASS = 'w-full rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent'; -const COMMON_TYPES = ['user_login', 'chat_activity', 'conversation_creation', 'conversation_deletion', - 'document_creation', 'document_deletion', 'token_usage', 'group_status_change', 'public_workspace_status_change']; +const PAGE_SIZE = 50; -function readFilters(params: URLSearchParams): Filters { - const end = new Date().toISOString().slice(0, 10); - const start = new Date(`${end}T00:00:00Z`); - start.setUTCDate(start.getUTCDate() - 29); - const fields = Object.fromEntries(FILTER_KEYS.map((key) => [key, params.get(key) ?? ''])) as Record; - fields.start_date ||= params.get('date') || start.toISOString().slice(0, 10); - fields.end_date ||= params.get('date') || end; - if (fields.workspace_type === 'public_workspace') fields.workspace_type = 'public'; - return { - ...fields, - activity_type: [...new Set(params.getAll('activity_type').flatMap((value) => value.split(',')).filter((value) => value && value !== 'all'))], - }; -} - -function filterParams(filters: Filters): URLSearchParams { - const params = new URLSearchParams(); - FILTER_KEYS.forEach((key) => { if (filters[key]) params.set(key, filters[key]); }); - filters.activity_type.forEach((value) => params.append('activity_type', value)); - return params; -} - -function recordText(record: ActivityRecord, ...path: string[]): string { - let value: unknown = record; - for (const part of path) { - if (typeof value !== 'object' || value === null) return ''; - value = (value as Record)[part]; - } - return typeof value === 'string' ? value : ''; -} - -function recordUser(record: ActivityRecord): string { - return recordText(record, 'user_id') || recordText(record, 'changed_by', 'user_id') || recordText(record, 'admin_user_id'); -} +type FilterPillId = 'date' | 'activity' | 'person' | 'workspace'; -function recordGroup(record: ActivityRecord): string { - return recordText(record, 'workspace_context', 'group_id') || recordText(record, 'group_id') || recordText(record, 'group', 'group_id'); +/** + * A filter chosen from the log or the drawer replaces the rows it was chosen from, so focus + * moves to that filter's pill, which stays on screen and now shows the new value. + */ +function focusPill(pill: FilterPillId) { + requestAnimationFrame(() => { + const pills = Array.from(document.querySelectorAll('[data-filter-pill]')); + pills.find((element) => element.dataset.filterPill === pill)?.focus(); + }); } -function recordWorkspace(record: ActivityRecord): string { - return recordText(record, 'workspace_context', 'public_workspace_id') || recordText(record, 'public_workspace_id'); -} - -function ActivityDetail({ record, onClose }: { record: ActivityRecord; onClose: () => void }) { - const user = recordUser(record); - const group = recordGroup(record); - const workspace = recordWorkspace(record); - const approval = recordText(record, 'approval_id') || recordText(record, 'approval', 'id'); - return ( - -
- {[['Activity', recordText(record, 'activity_type') || 'Unknown'], ['UTC timestamp', record.timestamp], - ['Record ID', record.id], ['User', user || 'Not recorded'], - ['Workspace type', recordText(record, 'workspace_type') || 'Not recorded']].map(([label, value]) => ( -
{label}
{value}
- ))} -
- -

Raw JSON

-
-                {JSON.stringify(record, null, 2)}
-            
-
- ); +interface KnownNames { + people: Record; + workspaces: Record; } export function ActivityLogsSection() { const [params, setParams] = useSearchParams(); const paramString = params.toString(); - const applied = useMemo(() => readFilters(new URLSearchParams(paramString)), [paramString]); - const query = filterParams(applied).toString(); - const [draft, setDraft] = useState(applied); + // Relative ranges ("Last 7 days") end today in UTC. Changing a filter re-reads the clock; + // `day` makes Refresh, an export or returning to the tab re-read it too, so a page left + // open past UTC midnight does not keep querying yesterday's window. + const [day, setDay] = useState(() => todayUtc()); + const filters = useMemo(() => readFilters(new URLSearchParams(paramString)), [paramString, day]); + const canonical = filterParams(filters).toString(); + const query = apiParams(filters).toString(); const [paging, setPaging] = useState<{ query: string; cursors: (string | null)[]; index: number }>({ query, cursors: [null], index: 0 }); const currentPaging = paging.query === query ? paging : { query, cursors: [null], index: 0 }; const cursor = currentPaging.cursors[currentPaging.index]; @@ -125,33 +66,51 @@ export function ActivityLogsSection() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [summaryError, setSummaryError] = useState(''); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); const [refresh, setRefresh] = useState(0); - const [density, setDensity] = useState<'comfortable' | 'compact'>('comfortable'); - const [viewName, setViewName] = useState(''); - const [views, setViews] = useState([]); - const [storageError, setStorageError] = useState(''); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(''); + const [typeCatalog, setTypeCatalog] = useState([]); + const [known, setKnown] = useState({ people: {}, workspaces: {} }); const userId = useBootstrapStore((state) => state.data?.user.id ?? ''); - const storageKey = `simplechat.activity-views.${userId}`; + const settings = useUserSettingsStore((state) => state.settings); + const settingsLoading = useUserSettingsStore((state) => state.loading); + const settingsLoadError = useUserSettingsStore((state) => state.error); + const saveError = useUserSettingsStore((state) => state.saveError); + const updateSettings = useUserSettingsStore((state) => state.update); + const flushSettings = useUserSettingsStore((state) => state.flush); + const prefs = parseActivityLogPrefs(settings.v2ActivityLogPrefs); + const savedViews = useMemo(() => parseActivitySavedViews(settings.v2ActivityLogSavedViews), [settings.v2ActivityLogSavedViews]); + const importStarted = useRef(false); - useEffect(() => { setDraft(applied); setSelected(null); }, [applied]); + // Views saved by the browser-only version move to the account once, then leave this browser. useEffect(() => { + if (importStarted.current || settingsLoading || settingsLoadError || !userId) return; + let legacy: ReturnType = []; try { - const stored: unknown = JSON.parse(localStorage.getItem(storageKey) || '[]'); - if (!Array.isArray(stored) || stored.length > 20 || !stored.every((item: unknown) => ( - typeof item === 'object' && item !== null && 'name' in item && 'query' in item - && typeof item.name === 'string' && item.name.length <= 80 - && typeof item.query === 'string' && item.query.length <= 4096 - ))) throw new Error('Invalid saved views'); - setViews(stored as SavedView[]); - setStorageError(''); + legacy = readLegacyActivityViews(window.localStorage, userId); } catch { - setViews([]); - setStorageError('Saved views could not be read from this browser.'); + return; } - }, [storageKey]); + importStarted.current = true; + if (!legacy.length) return; + updateSettings({ v2ActivityLogSavedViews: mergeImportedActivityViews(savedViews, legacy) }); + void flushSettings().then(() => { + if (useUserSettingsStore.getState().saveError) return; + try { + window.localStorage.removeItem(legacyActivityViewsKey(userId)); + } catch { + // The views are on the account; a leftover local copy is harmless. + } + }); + }, [settingsLoading, settingsLoadError, userId, savedViews, updateSettings, flushSettings]); + + useEffect(() => { setSelected(null); }, [query, cursor]); + useEffect(() => { + const sync = () => { if (document.visibilityState === 'visible') setDay(todayUtc()); }; + document.addEventListener('visibilitychange', sync); + return () => document.removeEventListener('visibilitychange', sync); + }, []); useEffect(() => { const controller = new AbortController(); @@ -159,10 +118,25 @@ export function ActivityLogsSection() { setError(''); setData(null); const pageQuery = new URLSearchParams(query); - pageQuery.set('page_size', '50'); + pageQuery.set('page_size', String(PAGE_SIZE)); if (cursor) pageQuery.set('cursor', cursor); api.get(`/api/v2/control-center/activity-logs?${pageQuery}`, controller.signal) - .then((result) => { if (!controller.signal.aborted) setData(result); }) + .then((result) => { + if (controller.signal.aborted) return; + setData(result); + setKnown((previous) => { + const next: KnownNames = { people: { ...previous.people }, workspaces: { ...previous.workspaces } }; + for (const view of result.presentation ?? []) { + if (view.actor.id && view.actor.resolved) next.people[view.actor.id] = { name: view.actor.name, email: view.actor.email }; + if (view.workspace.id && view.workspace.name) next.workspaces[`${view.workspace.type}:${view.workspace.id}`] = view.workspace.name; + } + const person = result.filter_labels?.person; + if (person?.resolved) next.people[person.id] = { name: person.name, email: person.email }; + const workspace = result.filter_labels?.workspace; + if (workspace?.name) next.workspaces[`${workspace.type}:${workspace.id}`] = workspace.name; + return next; + }); + }) .catch((failure: unknown) => { if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : 'Unable to load activity. Retry or narrow the filters.'); }) @@ -175,48 +149,55 @@ export function ActivityLogsSection() { setSummary(null); setSummaryError(''); api.get(`/api/v2/control-center/activity-logs/summary?${query}`, controller.signal) - .then((result) => { if (!controller.signal.aborted) setSummary(result); }) + .then((result) => { + if (controller.signal.aborted) return; + setSummary(result); + if (result.type_catalog?.length) setTypeCatalog(result.type_catalog); + }) .catch((failure: unknown) => { - if (!controller.signal.aborted) setSummaryError(failure instanceof Error ? failure.message : 'Unable to load the summary. Retry.'); + if (!controller.signal.aborted) setSummaryError(failure instanceof Error ? failure.message : 'Unable to load the activity trend. Retry.'); }); return () => controller.abort(); }, [query, refresh]); - const update = (key: FilterKey, value: string) => setDraft((previous) => ({ ...previous, [key]: value })); - const apply = (event: FormEvent) => { event.preventDefault(); setParams(filterParams(draft)); }; - const toggleType = (type: string) => { - const types = applied.activity_type.includes(type) ? applied.activity_type.filter((item) => item !== type) : [...applied.activity_type, type]; - setParams(filterParams({ ...applied, activity_type: types })); + const changeFilters = useCallback((next: ActivityFilters, options?: FilterChangeOptions) => { + setParams(filterParams(next), { replace: options?.replace }); + }, [setParams]); + const filterPerson = (id: string) => { + setSelected(null); + changeFilters({ ...filters, user_id: id }); + focusPill('person'); }; - const persistViews = (next: SavedView[]) => { - try { - localStorage.setItem(storageKey, JSON.stringify(next)); - setViews(next); - setStorageError(''); - setViewName(''); - } catch { - setStorageError('This browser could not save the view. Check storage permissions and retry.'); - } + const filterWorkspace = (workspace: ActivityWorkspaceRef) => { + setSelected(null); + changeFilters({ ...filters, workspace_type: workspace.type as WorkspaceType, workspace_id: workspace.id }); + focusPill('workspace'); }; - const saveView = (event: FormEvent) => { - event.preventDefault(); - const name = viewName.trim(); - if (!name) return; - persistViews([...views.filter((view) => view.name !== name), { name, query }].slice(-20)); + const toggleType = (type: string) => { + changeFilters({ + ...filters, + activity_type: filters.activity_type.includes(type) + ? filters.activity_type.filter((item) => item !== type) + : [...filters.activity_type, type], + }); + focusPill('activity'); }; - const openPreset = (activityType: string) => { - const preset = readFilters(new URLSearchParams()); - const start = new Date(`${preset.end_date}T00:00:00Z`); - start.setUTCDate(start.getUTCDate() - 6); - preset.start_date = start.toISOString().slice(0, 10); - preset.activity_type = [activityType]; - setParams(filterParams(preset)); + const applyQuery = (value: string) => setParams(filterParams(readFilters(new URLSearchParams(value)))); + const updatePrefs = (partial: Partial) => updateSettings({ v2ActivityLogPrefs: { ...prefs, ...partial } }); + const reload = () => { + setDay(todayUtc()); + setPaging({ query, cursors: [null], index: 0 }); + setRefresh((value) => value + 1); }; + const exportCsv = async () => { + // Read the clock now, so a relative range exports the window it names today. + const exportQuery = apiParams(readFilters(new URLSearchParams(paramString))).toString(); + setDay(todayUtc()); setExporting(true); setExportError(''); try { - const response = await fetch(apiUrl(`/api/v2/control-center/activity-logs/export.csv?${query}`), { credentials: CREDENTIALS_MODE }); + const response = await fetch(apiUrl(`/api/v2/control-center/activity-logs/export.csv?${exportQuery}`), { credentials: CREDENTIALS_MODE }); if (!response.ok) throw new Error('Activity export failed. Check the filters and retry.'); const blob = await response.blob(); const url = URL.createObjectURL(blob); @@ -234,140 +215,132 @@ export function ActivityLogsSection() { setExporting(false); } }; - const types = [...new Set([...COMMON_TYPES, ...applied.activity_type, ...(summary?.facets.map((facet) => facet.activity_type) || [])])]; - const counts = new Map(summary?.facets.map((facet) => [facet.activity_type, facet.count])); - const cellClass = density === 'compact' ? 'px-4 py-2' : 'px-4 py-4'; + + const records = data?.items ?? []; + const views = records.map((record, index) => data?.presentation?.[index] ?? fallbackPresentation(record)); + const counts = useMemo(() => new Map((summary?.facets ?? []).map((facet) => [facet.activity_type, facet.count])), [summary]); + const typeLabels = useMemo(() => new Map(typeCatalog.map((option) => [option.activity_type, option.label])), [typeCatalog]); + const workspaceKey = filters.workspace_id ? `${filters.workspace_type}:${filters.workspace_id}` : ''; + const labels: ActivityFilterLabels = { + person: filters.user_id && known.people[filters.user_id] + ? { id: filters.user_id, ...known.people[filters.user_id], resolved: true } : undefined, + workspace: workspaceKey && known.workspaces[workspaceKey] + ? { type: filters.workspace_type, id: filters.workspace_id, name: known.workspaces[workspaceKey], resolved: true } : undefined, + }; + const selectedRecord = selected !== null ? records[selected] : undefined; return ( -
+
-

Activity Logs

-

Trace recorded activity across users and workspaces. Date filters use UTC.

-
- { setPaging({ query, cursors: [null], index: 0 }); setRefresh((value) => value + 1); }}> +
+

Activity Logs

+

+ Who did what, where and when. Select a person, activity or workspace in the log to filter by it. +

+
+
+ { + updateSettings({ v2ActivityLogSavedViews: upsertActivitySavedView(savedViews, name, canonical) }); + toast.success(`Saved view “${name.trim()}”.`); + }} + onRename={(id, name) => updateSettings({ v2ActivityLogSavedViews: renameActivitySavedView(savedViews, id, name) })} + onDelete={(view) => { + updateSettings({ v2ActivityLogSavedViews: removeActivitySavedView(savedViews, view.id) }); + toast.success(`Deleted saved view “${view.name}”.`); + }} + /> + void exportCsv()}> -
-
-
- - - - - - - - -
-
More filters -
- {(['group_id', 'public_workspace_id', 'status'] as const).map((key) => ( - - ))} -
-
-
- Apply filters - setParams(new URLSearchParams())}>Clear filters - Up to 366 days. CSV includes at most 10,000 records. -
-
+ {saveError ?

Your Activity Logs views or preferences could not be saved: {saveError}

: null} {exportError ?

{exportError}

: null} -
-
- openPreset('user_login')}>Recent logins - openPreset('token_usage')}>Recent token usage -
- -
- - Save current filters -
- {views.length ?
Manage saved views - {views.map((view) =>
{view.name} - persistViews(views.filter((item) => item.name !== view.name))}>Remove -
)}
: null} - {storageError ?

{storageError}

: null} -
- -

Activity in this range

- {summaryError ?

{summaryError}

: !summary ?

Loading summary...

: <> -

{summary.truncated - ? `Sampled: newest ${summary.sample_limit.toLocaleString()} matching records. Counts are not full-range totals.` - : `${summary.sample_size.toLocaleString()} matching records in this range.`} UTC buckets of {summary.bucket_days} day(s).

- ({ - type: 'bar', data: { labels: summary.histogram.map((bin) => bin.date), - datasets: [{ label: 'Matching records', data: summary.histogram.map((bin) => bin.count), backgroundColor: '#4f8cff' }] }, - options: cartesianOptions(theme, false), - })} /> -
Histogram data and date drill-through - - {summary.histogram.map((bin) => )} -
Activity histogram data
UTC bucket startRecords
- {bin.count.toLocaleString()}
-
+ + setParams(new URLSearchParams())} + onRememberName={(kind, key, name, email) => setKnown((previous) => kind === 'person' + ? { ...previous, people: { ...previous.people, [key]: { name, email: email ?? '' } } } + : { ...previous, workspaces: { ...previous.workspaces, [key]: name } })} + /> + + updatePrefs({ showTrend: !prefs.showTrend })} + onDrill={(start, end) => { + changeFilters({ ...filters, range: '', start_date: start, end_date: end }); + focusPill('date'); + }} + onToggleType={toggleType} + typeLabel={(type) => typeLabels.get(type) ?? humanize(type)} + /> + + 0} + hasNext={Boolean(data?.next_cursor)} + onPrevious={() => setPaging({ ...currentPaging, index: currentPaging.index - 1 })} + onNext={() => { + if (data?.next_cursor) { + setPaging({ query, cursors: [...currentPaging.cursors.slice(0, currentPaging.index + 1), data.next_cursor], index: currentPaging.index + 1 }); + } + }} + onRetry={() => setRefresh((value) => value + 1)} + onPrefsChange={updatePrefs} + onOpen={setSelected} + onFilterPerson={filterPerson} + onFilterWorkspace={filterWorkspace} + onToggleType={toggleType} + emptyActions={<> + {filters.range !== '90' ? ( + changeFilters({ ...filters, range: '90', ...presetDates('90') })}> + Widen to the last 90 days + + ) : null} + {!isDefaultFilters(filters) ? ( + setParams(new URLSearchParams())}>Reset filters + ) : null} } -
-
Activity types -
{types.map((type) => ( - - ))}
-
-
- Newest first. Page {currentPaging.index + 1}{data ? ` · ${data.items.length} records` : ''} - -
-
-
- - - {['UTC time', 'Activity', 'User', 'Workspace', 'Details'].map((label) => )} - - {loading ? - : error ? - : !data?.items.length ? - : data.items.map((record) => - - - - - - )} - -
Activity logs in newest-first order
{label}
Loading activity...
{error}
No activity matches these filters. Widen the dates or clear a filter.
{record.timestamp.replace('T', ' ').replace(/\+00:00$|Z$/, '')}{recordText(record, 'activity_type').replaceAll('_', ' ') || 'Unknown'}{recordUser(record) || 'Not recorded'}{recordText(record, 'workspace_type') || 'Not recorded'}{recordGroup(record) || recordWorkspace(record) ? {recordGroup(record) || recordWorkspace(record)} : null} setSelected(record)}>Inspect
-
-
- setPaging({ ...currentPaging, index: currentPaging.index - 1 })}>Previous - { - if (data?.next_cursor) setPaging({ query, cursors: [...currentPaging.cursors.slice(0, currentPaging.index + 1), data.next_cursor], index: currentPaging.index + 1 }); - }}>Next -
-
- {selected ? setSelected(null)} /> : null} -
+ /> + + {selectedRecord && selected !== null ? ( + setSelected(null)} + onStep={(index) => setSelected(Math.max(0, Math.min(records.length - 1, index)))} + onFilterPerson={filterPerson} + onFilterWorkspace={filterWorkspace} + /> + ) : null} + ); } diff --git a/application/v2_ui/src/components/controlCenter/activityLogs/ActivityDetailDrawer.tsx b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityDetailDrawer.tsx new file mode 100644 index 000000000..e95b7445f --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityDetailDrawer.tsx @@ -0,0 +1,169 @@ +// ActivityDetailDrawer.tsx +// One record, read the way an investigation needs it: what happened, who did it, where, the +// recorded specifics, then the raw document. Previous and Next step through the page without +// closing the drawer. + +import { Link } from 'react-router-dom'; +import { ChevronLeft, ChevronRight, Copy, Filter } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { + WORKSPACE_TYPE_LABELS, formatActivityTime, workspaceName, + type ActivityPresentation, type ActivityRecord, type ActivityTimeZone, type ActivityWorkspaceRef, +} from '../../../lib/activityLogs'; +import { toast } from '../../../stores/toastStore'; +import { GlassButton } from '../../ui/primitives'; +import { DetailDrawer, StatusBadge } from '../ControlCenterPrimitives'; +import { categoryIcon } from './ActivityTable'; + +function text(record: ActivityRecord, ...path: string[]): string { + let value: unknown = record; + for (const part of path) { + if (typeof value !== 'object' || value === null) return ''; + value = (value as Record)[part]; + } + return typeof value === 'string' ? value : ''; +} + +function DrawerSection({ title, children, actions }: { title: string; children: ReactNode; actions?: ReactNode }) { + return ( +
+

{title}

+ {children} + {actions ?
{actions}
: null} +
+ ); +} + +function Facts({ rows }: { rows: [string, ReactNode][] }) { + const shown = rows.filter(([, value]) => value !== '' && value !== null && value !== undefined); + if (!shown.length) return

Nothing further was recorded.

; + return ( +
+ {shown.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +const LINK_CLASS = 'inline-flex h-8 items-center gap-1.5 rounded-xl px-3 text-sm font-medium text-accent hover:bg-accent-soft focus-visible:outline-2 focus-visible:outline-accent'; + +export function ActivityDetailDrawer({ + record, view, index, total, timeZone, onClose, onStep, onFilterPerson, onFilterWorkspace, +}: { + record: ActivityRecord; + view: ActivityPresentation; + index: number; + total: number; + timeZone: ActivityTimeZone; + onClose: () => void; + onStep: (index: number) => void; + onFilterPerson: (id: string) => void; + onFilterWorkspace: (workspace: ActivityWorkspaceRef) => void; +}) { + const Icon = categoryIcon(view.category); + const time = formatActivityTime(record.timestamp, timeZone); + const actor = view.actor; + const workspace = view.workspace; + const approval = text(record, 'approval_id') || text(record, 'approval', 'id'); + const approvalGroup = workspace.type === 'group' ? workspace.id : ''; + const json = JSON.stringify(record, null, 2); + const copy = async () => { + try { + await navigator.clipboard.writeText(json); + toast.success('Raw JSON copied.'); + } catch { + toast.error('The browser blocked copying. Select the JSON and copy it instead.'); + } + }; + + return ( + +
+
+ Record {index + 1} of {total} on this page +
+ onStep(index - 1)} aria-label="Previous record"> + + = total - 1} onClick={() => onStep(index + 1)} aria-label="Next record"> + Next +
+
+ +
+

+

+

{view.summary}

+ {view.detail ?

{view.detail}

: null} +

{time.primary}{time.secondary ? ` · ${time.secondary}` : ''}

+
+ + + onFilterPerson(actor.id)}> + + Open in Users + : null}> + {actor.id} : ''], + ]} /> + + + + onFilterWorkspace(workspace)}> + + + {workspace.type === 'group' ? 'Open group' : 'Open workspace'} + + : null}> + {workspace.id} : ''], + ]} /> + + + + [fact.label, fact.value] as [string, ReactNode])} /> + + + + View approval + + ) : null}> + {view.activity_type || 'Not recorded'}], + ['Time (UTC)', time.utc], + ['Time (local)', time.local], + ['Record ID', {record.id}], + ]} /> + + +
+ Raw JSON +
+ void copy()}> + +
{json}
+
+
+
+
+ ); +} diff --git a/application/v2_ui/src/components/controlCenter/activityLogs/ActivityFilterBar.tsx b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityFilterBar.tsx new file mode 100644 index 000000000..d2e508a26 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityFilterBar.tsx @@ -0,0 +1,478 @@ +// ActivityFilterBar.tsx +// One toolbar row that is also the investigation's state: a search field plus Azure-portal +// style pills. Each pill shows its value in human terms (names, not IDs) and applies as soon +// as it changes, so there is no separate Apply step. + +import { useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'; +import { clsx } from 'clsx'; +import { Plus, Search, X } from 'lucide-react'; +import { + DEFAULT_RANGE, RANGE_PRESETS, TOKEN_TYPES, dateRangeLabel, humanize, isDefaultFilters, looksLikeId, + presetDates, rangeValidationError, shortId, + type ActivityFilterLabels, type ActivityFilters, type ActivityPersonOption, type ActivityTypeOption, + type ActivityWorkspaceOption, type WorkspaceType, +} from '../../../lib/activityLogs'; +import { GlassButton } from '../../ui/primitives'; +import { EntityCombobox, type EntityResult } from './EntityCombobox'; +import { AnchoredPopover, FilterPill, usePopover } from './FilterPill'; + +const SEARCH_DEBOUNCE_MS = 400; +const FIELD = 'h-9 w-full rounded-lg border border-edge bg-surface-1 px-2.5 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none'; +const OPTION = 'flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm transition-colors focus-visible:outline-2 focus-visible:outline-accent'; + +type ExtraFilter = 'model' | 'token_type' | 'status'; +const EXTRA_FILTERS: { key: ExtraFilter; label: string }[] = [ + { key: 'model', label: 'Model' }, + { key: 'token_type', label: 'Token type' }, + { key: 'status', label: 'Recorded status' }, +]; + +export interface FilterChangeOptions { + replace?: boolean; +} + +function optionClass(selected: boolean) { + return clsx(OPTION, selected ? 'bg-accent-soft font-medium text-accent' : 'text-text-1 hover:bg-surface-2'); +} + +function PopoverHeading({ children }: { children: string }) { + return

{children}

; +} + +function DatePopover({ filters, onChange, close }: { + filters: ActivityFilters; + onChange: (next: ActivityFilters) => void; + close: () => void; +}) { + const [custom, setCustom] = useState(!filters.range); + const [start, setStart] = useState(filters.start_date); + const [end, setEnd] = useState(filters.end_date); + const [error, setError] = useState(''); + return ( +
+ Date range +
+ {RANGE_PRESETS.map((preset) => ( + + ))} + +
+ {custom ? ( +
{ + event.preventDefault(); + const problem = rangeValidationError(start, end); + setError(problem); + if (problem) return; + onChange({ ...filters, range: '', start_date: start, end_date: end }); + close(); + }}> +
+ + +
+ {error ?

{error}

: null} + Apply range +
+ ) : null} +

Days are UTC calendar days. Ranges can span up to 366 days.

+
+ ); +} + +function ActivityPopover({ filters, typeOptions, counts, onChange, close }: { + filters: ActivityFilters; + typeOptions: ActivityTypeOption[]; + counts: Map; + onChange: (next: ActivityFilters) => void; + close: () => void; +}) { + const [find, setFind] = useState(''); + const groups = useMemo(() => { + const known = new Map(typeOptions.map((option) => [option.activity_type, option])); + const extra = [...new Set([...counts.keys(), ...filters.activity_type])] + .filter((type) => !known.has(type)) + .map((type) => ({ activity_type: type, label: humanize(type) || 'Unknown', category: 'other', category_label: 'Other' })); + const term = find.trim().toLowerCase(); + const grouped = new Map(); + for (const option of [...typeOptions, ...extra]) { + if (term && !option.label.toLowerCase().includes(term) && !option.activity_type.includes(term)) continue; + const group = grouped.get(option.category) ?? { label: option.category_label, options: [] }; + group.options.push(option); + grouped.set(option.category, group); + } + return [...grouped.values()]; + }, [typeOptions, counts, filters.activity_type, find]); + const toggle = (type: string) => { + const selected = filters.activity_type.includes(type) + ? filters.activity_type.filter((item) => item !== type) + : [...filters.activity_type, type]; + onChange({ ...filters, activity_type: selected }); + }; + return ( +
+ Activity types + + setFind(event.target.value)} className={FIELD} /> +
+ {groups.length === 0 ?

No activity type matches “{find}”.

: null} + {groups.map((group) => ( +
+ {group.label} + {group.options.map((option) => { + const count = counts.get(option.activity_type); + return ( + + ); + })} +
+ ))} +
+
+

Counts cover the current date range and filters.

+
+ {filters.activity_type.length ? ( + onChange({ ...filters, activity_type: [] })}>Clear + ) : null} + Done +
+
+
+ ); +} + +function TextFilterPopover({ title, label, help, initial, suggestions, allowText = true, onApply }: { + title: string; + label: string; + help: string; + initial: string; + suggestions?: { value: string; label: string }[]; + allowText?: boolean; + onApply: (value: string) => void; +}) { + const [value, setValue] = useState(initial); + return ( +
+ {title} + {suggestions ? ( +
+ {suggestions.map((option) => ( + + ))} +
+ ) : null} + {allowText ? ( +
{ event.preventDefault(); onApply(value.trim()); }} className="space-y-2"> + +

{help}

+ Apply +
+ ) :

{help}

} +
+ ); +} + +function AddFilterMenu({ available, onAdd, buttonRef }: { + available: typeof EXTRA_FILTERS; + onAdd: (key: ExtraFilter) => void; + buttonRef: MutableRefObject; +}) { + const popover = usePopover(); + if (!available.length) return null; + return ( +
+ + {popover.open ? ( + popover.close(restoreFocus)}> +
+ {available.map((item) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +/** Lets an administrator filter by the ID of someone no longer in SimpleChat. */ +function rawPersonOption(term: string, people: ActivityPersonOption[]): EntityResult[] { + if (!looksLikeId(term) || people.some((person) => person.id === term)) return []; + return [{ + value: { id: term, display_name: '', email: '' }, + option: { key: `raw:${term}`, primary: `Filter by user ID “${term}”`, secondary: 'For someone no longer in SimpleChat', raw: true }, + }]; +} + +/** Lets an administrator filter by the ID of a workspace no longer in SimpleChat. */ +function rawWorkspaceOptions(term: string, workspaces: ActivityWorkspaceOption[], kind: WorkspaceType): EntityResult[] { + if (!looksLikeId(term) || workspaces.some((workspace) => workspace.id === term)) return []; + const types: ('group' | 'public')[] = kind === 'group' || kind === 'public' ? [kind] : ['group', 'public']; + return types.map((type) => ({ + value: { type, id: term, name: '' }, + option: { + key: `raw:${type}:${term}`, primary: `Filter by ${type === 'group' ? 'group' : 'public workspace'} ID “${term}”`, + secondary: 'For a workspace no longer in SimpleChat', raw: true, + }, + })); +} + +export function ActivityFilterBar({ filters, labels, typeOptions, counts, searchPeople, onChange, onReset, onRememberName }: { + filters: ActivityFilters; + labels?: ActivityFilterLabels; + typeOptions: ActivityTypeOption[]; + counts: Map; + searchPeople?: { matched: number; truncated: boolean }; + onChange: (next: ActivityFilters, options?: FilterChangeOptions) => void; + onReset: () => void; + /** Keeps a picked name on the pill while the filtered page loads. */ + onRememberName?: (kind: 'person' | 'workspace', key: string, name: string, email?: string) => void; +}) { + const [draft, setDraft] = useState(filters.search); + const [adding, setAdding] = useState(null); + // The search this field last sent to the URL, so its echo is not mistaken for a change. + const pushed = useRef(filters.search); + // An added filter whose value was just applied; its pill stays until the URL has the value. + const applying = useRef(null); + const addFilterButton = useRef(null); + const searchInput = useRef(null); + const latest = useRef({ filters, onChange }); + latest.current = { filters, onChange }; + + // Only a change made elsewhere (Reset, a saved view, Back) replaces the text being typed. + // The URL echoing this field's own search must not, or it would drop a trailing space or + // a keystroke typed while the URL was updating. + useEffect(() => { + if (filters.search === pushed.current) return; + pushed.current = filters.search; + setDraft(filters.search); + }, [filters.search]); + useEffect(() => { + const next = draft.trim(); + if (next === filters.search) return undefined; + const timer = window.setTimeout(() => { + pushed.current = next; + latest.current.onChange({ ...latest.current.filters, search: next }, { replace: true }); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [draft, filters.search]); + useEffect(() => { + if (adding && filters[adding]) setAdding(null); + }, [adding, filters]); + + const focusAfterExtraPill = () => (addFilterButton.current ?? searchInput.current)?.focus(); + const typeLabels = new Map(typeOptions.map((option) => [option.activity_type, option.label])); + const types = filters.activity_type; + const activityValue = !types.length ? 'All' + : types.length === 1 ? (typeLabels.get(types[0]) ?? humanize(types[0])) + : `${types.length} types`; + const personValue = !filters.user_id ? 'Anyone' + : labels?.person?.name || labels?.person?.email || shortId(filters.user_id); + const workspaceKind = filters.workspace_type; + const workspaceLabel = filters.workspace_id && workspaceKind === 'group' ? 'Group' + : filters.workspace_id && workspaceKind === 'public' ? 'Public workspace' : 'Workspace'; + const workspaceValue = !workspaceKind ? 'Any' + : workspaceKind === 'personal' ? 'Personal' + : filters.workspace_id ? (labels?.workspace?.name || shortId(filters.workspace_id)) + : workspaceKind === 'group' ? 'All groups' : 'All public workspaces'; + const setWorkspace = (type: WorkspaceType, id = '') => onChange({ ...filters, workspace_type: type, workspace_id: id }); + const extraValue = (key: ExtraFilter) => key === 'token_type' + ? (TOKEN_TYPES.find((item) => item.value === filters.token_type)?.label ?? filters.token_type) + : key === 'status' && filters.status === 'failed' ? 'Failed or error' : filters[key]; + + return ( +
+
+
+
+ onChange({ ...filters, range: DEFAULT_RANGE, ...presetDates(DEFAULT_RANGE) })} + clearLabel="Reset date range to the last 30 days" popoverLabel="Choose a date range" width={300}> + {(close) => } + + 0} + onClear={() => onChange({ ...filters, activity_type: [] })} popoverLabel="Choose activity types" width={360}> + {(close) => } + + onChange({ ...filters, user_id: '' })} popoverLabel="Choose a person" width={340}> + {(close) => ( + + label="Find a person" + placeholder="Name, email or user ID" + endpoint="/api/v2/control-center/activity-logs/people" + emptyText="No SimpleChat user matches." + toOptions={(response, term) => [ + ...response.people.map((person) => ({ + value: person, + option: { key: person.id, primary: person.display_name || person.email || person.id, + secondary: person.display_name ? person.email : person.id }, + })), + ...rawPersonOption(term, response.people), + ]} + onSelect={(value) => { + const person = value as ActivityPersonOption; + if (person.display_name || person.email) { + onRememberName?.('person', person.id, person.display_name, person.email); + } + onChange({ ...filters, user_id: person.id }); + close(); + }} + footer={

Shows what this person did, including approvals and admin changes they made.

} + /> + )} +
+ setWorkspace('')} popoverLabel="Choose a workspace" width={360}> + {(close) => ( +
+
+ Workspace +
+ {([['', 'Any'], ['personal', 'Personal'], ['group', 'Groups'], ['public', 'Public']] as const).map(([type, text]) => ( + + ))} +
+
+ + label={workspaceKind === 'group' ? 'Find a group' : workspaceKind === 'public' ? 'Find a public workspace' : 'Find a group or public workspace'} + placeholder="Name or ID" + endpoint="/api/v2/control-center/activity-logs/workspaces" + emptyText="No group or public workspace matches." + toOptions={(response, term) => [ + ...response.workspaces + .filter((workspace) => workspaceKind !== 'group' && workspaceKind !== 'public' || workspace.type === workspaceKind) + .map((workspace) => ({ + value: workspace, + option: { key: `${workspace.type}:${workspace.id}`, primary: workspace.name || workspace.id, + secondary: workspace.name ? workspace.id : undefined, + badge: workspace.type === 'group' ? 'Group' : 'Public' }, + })), + ...rawWorkspaceOptions(term, response.workspaces, workspaceKind), + ]} + onSelect={(value) => { + const workspace = value as ActivityWorkspaceOption; + if (workspace.name) onRememberName?.('workspace', `${workspace.type}:${workspace.id}`, workspace.name); + setWorkspace(workspace.type, workspace.id); + close(); + }} + /> +
+ )} +
+ {EXTRA_FILTERS.filter((item) => filters[item.key] || adding === item.key).map((item) => ( + { + if (open) return; + if (applying.current === item.key) { + applying.current = null; + return; + } + // Closing an added filter without choosing a value removes its pill. + if (adding === item.key && !filters[item.key]) { + setAdding(null); + if (restoreFocus) requestAnimationFrame(focusAfterExtraPill); + } + }} + onClear={() => onChange({ ...filters, [item.key]: '' })} focusAfterClear={focusAfterExtraPill} + popoverLabel={`Filter by ${item.label.toLowerCase()}`}> + {(close) => ( + { + applying.current = item.key; + onChange({ ...filters, [item.key]: value }); + close(); + }} + /> + )} + + ))} + !filters[item.key] && adding !== item.key)} + onAdd={setAdding} /> + {!isDefaultFilters(filters) ? ( + + ) : null} +
+ {filters.search && searchPeople?.truncated ? ( +

+ “{filters.search}” matches more than 25 people, so the search includes only the first 25. Use the Person filter to pick one. +

+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/controlCenter/activityLogs/ActivityTable.tsx b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityTable.tsx new file mode 100644 index 000000000..2db2b8207 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityTable.tsx @@ -0,0 +1,267 @@ +// ActivityTable.tsx +// The log itself. Each row reads as a sentence: when, who, what happened and where. A +// person, activity type or workspace in a row is a one-click filter; the rest of the row +// opens the record's details. + +import type { MouseEvent, ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { + Activity, Bot, Coins, Database, FileText, Filter, Globe, LogIn, MessageSquare, ShieldCheck, Users, + type LucideIcon, +} from 'lucide-react'; +import { + WORKSPACE_TYPE_LABELS, formatActivityTime, personName, shortId, workspaceName, + type ActivityLogPrefs, type ActivityPresentation, type ActivityRecord, type ActivityWorkspaceRef, +} from '../../../lib/activityLogs'; +import { GlassButton, Skeleton } from '../../ui/primitives'; +import { StatusBadge } from '../ControlCenterPrimitives'; + +export const CATEGORY_ICONS: Record = { + sign_in: LogIn, + chat: MessageSquare, + documents: FileText, + tokens: Coins, + groups: Users, + public_workspaces: Globe, + administration: ShieldCheck, + agents: Bot, + data: Database, +}; + +export function categoryIcon(category: string): LucideIcon { + return CATEGORY_ICONS[category] ?? Activity; +} + +/** A presentation for records the server did not describe (older API or failed lookup). */ +export function fallbackPresentation(record: ActivityRecord): ActivityPresentation { + const type = typeof record.activity_type === 'string' ? record.activity_type : ''; + const userId = typeof record.user_id === 'string' ? record.user_id : ''; + const label = type ? type.replaceAll('_', ' ') : 'Unknown activity'; + return { + activity_type: type, + label: label.charAt(0).toUpperCase() + label.slice(1), + category: 'other', + summary: typeof record.description === 'string' && record.description ? record.description : 'Open details for the recorded fields.', + detail: '', + facts: [], + status: null, + actor: { id: userId, name: '', email: '', kind: userId ? 'user' : 'system', resolved: false }, + workspace: { type: typeof record.workspace_type === 'string' ? record.workspace_type : '', id: '', name: '', resolved: false }, + }; +} + +export interface ActivityRowHandlers { + onOpen: (index: number) => void; + onFilterPerson: (id: string) => void; + onFilterWorkspace: (workspace: ActivityWorkspaceRef) => void; + onToggleType: (type: string) => void; +} + +function stop(event: MouseEvent) { + event.stopPropagation(); +} + +function FilterButton({ label, onClick, children, className }: { + label: string; + onClick: () => void; + children: ReactNode; + className?: string; +}) { + return ( + + ); +} + +function PersonCell({ view, onFilterPerson }: { view: ActivityPresentation; onFilterPerson: (id: string) => void }) { + const actor = view.actor; + const name = personName(actor); + const secondary = actor.name && actor.email ? actor.email : actor.id && !actor.resolved ? shortId(actor.id) : ''; + if (actor.kind === 'system') return System; + const body = <> + {name} + {secondary ? {secondary} : null} + ; + if (!actor.id) return {body}; + return onFilterPerson(actor.id)}>{body}; +} + +function WorkspaceCell({ view, onFilterWorkspace }: { view: ActivityPresentation; onFilterWorkspace: (workspace: ActivityWorkspaceRef) => void }) { + const workspace = view.workspace; + const typeLabel = WORKSPACE_TYPE_LABELS[workspace.type]; + if (!workspace.id) { + return typeLabel ? {typeLabel} : No workspace; + } + const name = workspaceName(workspace); + return ( + onFilterWorkspace(workspace)}> + {typeLabel} + {name} + + ); +} + +function ActivityTypeCell({ view, selected, onToggleType }: { view: ActivityPresentation; selected: boolean; onToggleType: (type: string) => void }) { + const Icon = categoryIcon(view.category); + const content = + ; + if (!view.activity_type) return {content}; + return ( + onToggleType(view.activity_type)} className={selected ? 'text-accent' : 'text-text-1'}> + {content} + + ); +} + +function DetailsButton({ view, timeLabel, onOpen }: { view: ActivityPresentation; timeLabel: string; onOpen: () => void }) { + return ( + + ); +} + +export function ActivityTable({ + records, views, loading, error, prefs, selectedTypes, pageNumber, hasPrevious, hasNext, onPrevious, onNext, + onRetry, emptyActions, onPrefsChange, ...handlers +}: { + records: ActivityRecord[]; + views: ActivityPresentation[]; + loading: boolean; + error: string; + prefs: ActivityLogPrefs; + selectedTypes: string[]; + pageNumber: number; + hasPrevious: boolean; + hasNext: boolean; + onPrevious: () => void; + onNext: () => void; + onRetry: () => void; + emptyActions: ReactNode; + onPrefsChange: (prefs: Partial) => void; +} & ActivityRowHandlers) { + const compact = prefs.density === 'compact'; + const cell = compact ? 'px-3 py-1.5' : 'px-3 py-2.5'; + const now = new Date(); + const segment = (selected: boolean) => clsx( + 'px-2.5 py-1 text-xs transition-colors first:rounded-l-md last:rounded-r-md focus-visible:outline-2 focus-visible:outline-accent', + selected ? 'bg-accent-soft font-medium text-accent' : 'text-text-3 hover:bg-surface-2 hover:text-text-1', + ); + const rows = records.map((record, index) => ({ record, index, view: views[index], time: formatActivityTime(record.timestamp, prefs.timeZone, now) })); + const status = loading ? 'Loading activity…' : error ? '' : `Page ${pageNumber} · ${records.length.toLocaleString()} ${records.length === 1 ? 'record' : 'records'}, newest first`; + + return ( +
+
+

{status}

+
+
+ + +
+
+ + +
+
+
+ + {error ? ( +
+ {error}{' '} + +
+ ) : loading ? ( + + ) : !records.length ? ( +
+

No activity matches these filters.

+

Widen the date range, or remove a filter to see more.

+
{emptyActions}
+
+ ) : ( + <> +
+ + + + {/* Details takes the remaining width: it carries the most information. */} + + + + + {['Time', 'Person', 'Activity', 'Details', 'Workspace'].map((label) => ( + + ))} + + + + {rows.map(({ record, index, view, time }) => ( + { + // Selecting text to copy it should not open the record. + if (!window.getSelection()?.toString()) handlers.onOpen(index); + }} + className="cursor-pointer align-top transition-colors hover:bg-surface-2"> + + + + + + + ))} + +
Activity records, newest first. Select a person, activity or workspace to filter by it; select the details to open the record.
{label}
+ {time.primary} + {!compact && time.secondary ? {time.secondary} : null} + + + handlers.onOpen(index)} />
+
+
    + {rows.map(({ record, index, view, time }) => ( +
  • +
    + + {time.primary} +
    + handlers.onOpen(index)} /> +
    + + +
    +
  • + ))} +
+ + )} + +
+ Previous + Next +
+
+ ); +} diff --git a/application/v2_ui/src/components/controlCenter/activityLogs/ActivityTrendStrip.tsx b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityTrendStrip.tsx new file mode 100644 index 000000000..9337df0d6 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityTrendStrip.tsx @@ -0,0 +1,149 @@ +// ActivityTrendStrip.tsx +// A slim overview of the filtered range: how much activity there is, when it happened, and +// which types dominate. Every bar and type is a way to narrow the log below it. + +import { useRef, useState } from 'react'; +import { clsx } from 'clsx'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { formatUtcDay, shiftUtcDate, type ActivityFilters, type ActivitySummary } from '../../../lib/activityLogs'; +import { GlassPanel, Skeleton } from '../../ui/primitives'; + +const TOP_TYPES = 4; + +function bucketLabel(start: string, days: number, rangeEnd: string): { label: string; end: string } { + const end = days > 1 ? shiftUtcDate(start, days - 1) : start; + const clipped = end > rangeEnd ? rangeEnd : end; + return { + label: clipped === start ? formatUtcDay(start, false) : `${formatUtcDay(start, false)} – ${formatUtcDay(clipped, false)}`, + end: clipped, + }; +} + +export function ActivityTrendStrip({ summary, error, filters, expanded, onToggleExpanded, onDrill, onToggleType, typeLabel }: { + summary: ActivitySummary | null; + error: string; + filters: ActivityFilters; + expanded: boolean; + onToggleExpanded: () => void; + onDrill: (start: string, end: string) => void; + onToggleType: (type: string) => void; + typeLabel: (type: string) => string; +}) { + const [focusIndex, setFocusIndex] = useState(0); + const bars = useRef<(HTMLButtonElement | null)[]>([]); + const histogram = summary?.histogram ?? []; + const max = Math.max(1, ...histogram.map((bin) => bin.count)); + const topTypes = [...(summary?.facets ?? [])].sort((a, b) => b.count - a.count || a.activity_type.localeCompare(b.activity_type)).slice(0, TOP_TYPES); + const typeCount = summary?.facets.length ?? 0; + const total = summary?.sample_size ?? 0; + const headline = !summary ? 'Counting activity…' + : summary.truncated ? `More than ${summary.sample_limit.toLocaleString()} records` + : `${total.toLocaleString()} ${total === 1 ? 'record' : 'records'}`; + + const moveFocus = (index: number) => { + const next = Math.max(0, Math.min(histogram.length - 1, index)); + setFocusIndex(next); + bars.current[next]?.focus(); + }; + + return ( + +
+

+ {headline} + {summary ? + {' · '}{summary.bucket_days === 1 ? 'by UTC day' : `in ${summary.bucket_days}-day UTC periods`} + {summary.truncated ? ` · trend and counts use the newest ${summary.sample_limit.toLocaleString()}` : ''} + : null} +

+ +
+ {expanded ? ( +
+ {error ? ( +

{error}

+ ) : !summary ? ( + <> + + + + ) : ( + <> +
+
+ {histogram.map((bin, index) => { + const { label, end } = bucketLabel(bin.date, summary.bucket_days, filters.end_date); + const description = `${label} (UTC): ${bin.count.toLocaleString()} ${bin.count === 1 ? 'record' : 'records'}`; + return ( + + ); + })} +
+ {histogram.length ? ( + + ) : null} +
+
+

Top activity

+ {topTypes.length ? ( +
    + {topTypes.map((facet) => { + const selected = filters.activity_type.includes(facet.activity_type); + const label = facet.label || typeLabel(facet.activity_type); + return ( +
  • + +
  • + ); + })} +
+ ) :

No activity in this range.

} + {typeCount > TOP_TYPES ? ( +

{typeCount - TOP_TYPES} more {typeCount - TOP_TYPES === 1 ? 'type' : 'types'} in the Activity filter.

+ ) : null} +
+ + )} +
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/controlCenter/activityLogs/ActivityViewsMenu.tsx b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityViewsMenu.tsx new file mode 100644 index 000000000..de4bb8f5e --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/activityLogs/ActivityViewsMenu.tsx @@ -0,0 +1,145 @@ +// ActivityViewsMenu.tsx +// Quick views for the questions administrators ask most, and the administrator's own saved +// views, stored on their account so they follow them to any browser. + +import { useState } from 'react'; +import { clsx } from 'clsx'; +import { Bookmark, Check, ChevronDown, Pencil, Trash2, X } from 'lucide-react'; +import type { ActivityLogSavedView } from '../../../lib/activityLogs'; +import { MAX_ACTIVITY_SAVED_VIEWS, MAX_ACTIVITY_VIEW_NAME_LENGTH } from '../../../lib/activityLogSavedViews'; +import { GlassButton } from '../../ui/primitives'; +import { AnchoredPopover, usePopover } from './FilterPill'; + +export interface QuickView { + id: string; + name: string; + description: string; + query: string; +} + +export const QUICK_VIEWS: QuickView[] = [ + { id: 'sign-ins', name: 'Recent sign-ins', description: 'User logins in the last 7 days', query: 'range=7&activity_type=user_login' }, + { id: 'tokens', name: 'Token usage', description: 'Token usage in the last 7 days', query: 'range=7&activity_type=token_usage' }, + { + id: 'failures', name: 'Document processing failures', description: 'Documents that failed or errored in the last 30 days', + query: 'activity_type=document_creation&status=failed', + }, +]; + +const ROW = 'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors focus-visible:outline-2 focus-visible:outline-accent'; +const ICON_BUTTON = 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-3 hover:bg-surface-2 hover:text-text-1 focus-visible:outline-2 focus-visible:outline-accent'; + +export function ActivityViewsMenu({ views, currentQuery, unavailable, onApply, onSave, onRename, onDelete }: { + views: ActivityLogSavedView[]; + currentQuery: string; + /** + * Why saved views cannot be changed right now. Set while the account's settings are + * loading or failed to load, so a save cannot replace views it never read. + */ + unavailable?: string; + onApply: (query: string) => void; + onSave: (name: string) => void; + onRename: (id: string, name: string) => void; + onDelete: (view: ActivityLogSavedView) => void; +}) { + const popover = usePopover(); + const [name, setName] = useState(''); + const [editing, setEditing] = useState<{ id: string; name: string } | null>(null); + const replaces = views.some((view) => view.name.toLowerCase() === name.trim().toLowerCase()); + const apply = (query: string) => { + popover.close(true); + onApply(query); + }; + + return ( +
+ + {popover.open ? ( + { setEditing(null); popover.close(restoreFocus); }}> +
+
+

Quick views

+
    + {QUICK_VIEWS.map((view) => ( +
  • + +
  • + ))} +
+
+
+

Your saved views

+

Saved to your account, so they are here on any browser.

+ {unavailable ? ( +

{unavailable}

+ ) : views.length ? ( +
    + {views.map((view) => editing?.id === view.id ? ( +
  • +
    { + event.preventDefault(); + onRename(view.id, editing.name); + setEditing(null); + }}> + + setEditing({ id: view.id, name: event.target.value })} + className="h-8 min-w-0 flex-1 rounded-lg border border-edge bg-surface-1 px-2 text-sm text-text-1 focus:border-accent focus:outline-none" /> + + +
    +
  • + ) : ( +
  • + + + +
  • + ))} +
+ ) :

No saved views yet. Save the current filters to reopen them later.

} +
+ {unavailable ? null :
{ + event.preventDefault(); + if (!name.trim()) return; + onSave(name); + setName(''); + }}> + +
+ setName(event.target.value)} + className="h-8 min-w-0 flex-1 rounded-lg border border-edge bg-surface-1 px-2 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none" /> + Save view +
+

+ {replaces ? 'Replaces the saved view with this name.' + : `Relative ranges such as “Last 7 days” stay relative. Up to ${MAX_ACTIVITY_SAVED_VIEWS} views.`} +

+
} +
+
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/controlCenter/activityLogs/EntityCombobox.tsx b/application/v2_ui/src/components/controlCenter/activityLogs/EntityCombobox.tsx new file mode 100644 index 000000000..1517fd4b2 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/activityLogs/EntityCombobox.tsx @@ -0,0 +1,176 @@ +// EntityCombobox.tsx +// A searchable list for picking a person or workspace by name instead of pasting an ID. +// Implements the ARIA combobox-with-listbox pattern: the input owns focus and the active +// option is announced through aria-activedescendant. + +import { useEffect, useId, useState, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { Search } from 'lucide-react'; +import { api } from '../../../lib/apiClient'; + +export interface EntityOption { + key: string; + primary: string; + secondary?: string; + badge?: string; + /** An option that applies the typed text as an ID rather than a search match. */ + raw?: boolean; +} + +export interface EntityResult { + option: EntityOption; + value: unknown; +} + +const MIN_QUERY_LENGTH = 2; +const SEARCH_DEBOUNCE_MS = 250; + +export function EntityCombobox({ + label, + placeholder, + endpoint, + toOptions, + onSelect, + emptyText, + footer, +}: { + label: string; + placeholder: string; + endpoint: string; + /** Options for a response. Recomputed on every render, so they follow the caller's state. */ + toOptions: (response: Result, term: string) => EntityResult[]; + onSelect: (value: unknown, option: EntityOption) => void; + emptyText: string; + footer?: ReactNode; +}) { + const id = useId(); + const listId = `${id}-list`; + const [query, setQuery] = useState(''); + const [response, setResponse] = useState<{ data: Result; term: string } | null>(null); + const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle'); + const [active, setActive] = useState(0); + const [attempt, setAttempt] = useState(0); + const term = query.trim(); + + useEffect(() => { + if (term.length < MIN_QUERY_LENGTH) { + setResponse(null); + setState('idle'); + return undefined; + } + const controller = new AbortController(); + setState('loading'); + const timer = window.setTimeout(() => { + api.get(`${endpoint}?q=${encodeURIComponent(term)}`, controller.signal) + .then((data) => { + if (controller.signal.aborted) return; + setResponse({ data, term }); + setActive(0); + setState('ready'); + }) + .catch(() => { + if (!controller.signal.aborted) setState('error'); + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + controller.abort(); + window.clearTimeout(timer); + }; + }, [endpoint, term, attempt]); + + const results = state === 'ready' && response ? toOptions(response.data, response.term) : []; + const matches = results.filter((result) => !result.option.raw).length; + const expanded = results.length > 0; + const activeIndex = Math.min(active, Math.max(0, results.length - 1)); + const choose = (index: number) => { + const result = results[index]; + if (result) onSelect(result.value, result.option); + }; + + return ( +
+ +
+
+
    + {results.map((result, index) => ( +
  • setActive(index)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => choose(index)} + className={clsx( + 'flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm', + index === activeIndex ? 'bg-accent-soft text-text-1' : 'text-text-2', + result.option.raw && 'border-t border-edge', + )} + > + + + {result.option.primary} + + {result.option.secondary ? ( + {result.option.secondary} + ) : null} + + {result.option.badge ? ( + + {result.option.badge} + + ) : null} +
  • + ))} +
+

+ {state === 'idle' ? `Type at least ${MIN_QUERY_LENGTH} characters of a name, email or ID.` + : state === 'loading' ? 'Searching…' + : state === 'error' ? null + : matches === 0 ? emptyText + : `${matches} ${matches === 1 ? 'match' : 'matches'}. Use the arrow keys, then Enter.`} +

+ {state === 'error' ? ( +

+ Search failed.{' '} + +

+ ) : null} + {footer} +
+ ); +} diff --git a/application/v2_ui/src/components/controlCenter/activityLogs/FilterPill.tsx b/application/v2_ui/src/components/controlCenter/activityLogs/FilterPill.tsx new file mode 100644 index 000000000..5ccb0021a --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/activityLogs/FilterPill.tsx @@ -0,0 +1,225 @@ +// FilterPill.tsx +// Azure-portal-style filter pills: each pill shows its filter's current value and opens the +// editor for it. The popover is portalled and fixed-positioned so the page's scrolling +// containers never clip it, and it follows the incumbent picker pattern: outside click +// closes, Escape closes and returns focus to the pill. + +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react'; +import { createPortal } from 'react-dom'; +import { clsx } from 'clsx'; +import { ChevronDown, X } from 'lucide-react'; + +const POPOVER_GAP = 6; +const VIEWPORT_MARGIN = 8; +const POPOVER_MAX_HEIGHT = 480; + +export function AnchoredPopover({ + anchorRef, + label, + onClose, + width = 320, + align = 'left', + children, +}: { + anchorRef: RefObject; + label: string; + /** `restoreFocus` is true for Escape and explicit closes, false for outside clicks. */ + onClose: (restoreFocus: boolean) => void; + width?: number; + align?: 'left' | 'right'; + children: ReactNode; +}) { + const panel = useRef(null); + const [position, setPosition] = useState<{ top: number; left: number; maxHeight: number; width: number } | null>(null); + const closeRef = useRef(onClose); + closeRef.current = onClose; + + useLayoutEffect(() => { + const measure = () => { + const rect = anchorRef.current?.getBoundingClientRect(); + if (!rect) return; + const panelWidth = Math.min(width, window.innerWidth - VIEWPORT_MARGIN * 2); + const below = window.innerHeight - rect.bottom - VIEWPORT_MARGIN * 2; + const above = rect.top - VIEWPORT_MARGIN * 2; + const placeBelow = below >= Math.min(above, 280); + const maxHeight = Math.max(160, Math.min(POPOVER_MAX_HEIGHT, placeBelow ? below : above)); + const left = align === 'right' ? rect.right - panelWidth : rect.left; + setPosition({ + top: placeBelow ? rect.bottom + POPOVER_GAP : Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - POPOVER_GAP), + left: Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - panelWidth - VIEWPORT_MARGIN)), + maxHeight, + width: panelWidth, + }); + }; + measure(); + window.addEventListener('resize', measure); + window.addEventListener('scroll', measure, true); + return () => { + window.removeEventListener('resize', measure); + window.removeEventListener('scroll', measure, true); + }; + }, [anchorRef, width, align]); + + useEffect(() => { + const closeOutside = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && !panel.current?.contains(target) && !anchorRef.current?.contains(target)) { + closeRef.current(false); + } + }; + document.addEventListener('pointerdown', closeOutside); + return () => document.removeEventListener('pointerdown', closeOutside); + }, [anchorRef]); + + const positioned = position !== null; + useEffect(() => { + if (!positioned) return; + const preferred = panel.current?.querySelector('[data-autofocus]'); + const first = panel.current?.querySelector( + 'input:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + (preferred ?? first ?? panel.current)?.focus(); + }, [positioned]); + + return createPortal( +
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeRef.current(true); + } + }} + onBlur={(event) => { + // Tabbing past the last control leaves the popover; close it rather than + // strand an open panel behind the focus. + const next = event.relatedTarget; + if (next instanceof Node && !panel.current?.contains(next) && !anchorRef.current?.contains(next)) { + closeRef.current(false); + } + }} + > + {children} +
, + document.body, + ); +} + +/** Open state for a trigger and its popover, with focus returned to the trigger on close. */ +export function usePopover(defaultOpen = false, onOpenChange?: (open: boolean, restoreFocus: boolean) => void) { + const [open, setOpen] = useState(defaultOpen); + const triggerRef = useRef(null); + const anchorRef = useRef(null); + const change = (next: boolean, restoreFocus = false) => { + setOpen(next); + onOpenChange?.(next, restoreFocus); + }; + return { + open, + triggerRef, + anchorRef, + // Closing from the trigger itself counts as a deliberate close, like Escape. + toggle: () => change(!open, open), + /** `restoreFocus` is false when the user clicked elsewhere, so focus stays where they clicked. */ + close: (restoreFocus = true) => { + change(false, restoreFocus); + if (restoreFocus) requestAnimationFrame(() => triggerRef.current?.focus()); + }, + }; +} + +export function FilterPill({ + label, + value, + active, + onClear, + clearLabel, + focusAfterClear, + popoverLabel, + pillId, + width, + defaultOpen = false, + onOpenChange, + children, +}: { + label: string; + value: string; + active: boolean; + onClear?: () => void; + clearLabel?: string; + /** + * Where keyboard focus goes when the clear button removes itself. Defaults to the pill; + * a pill that disappears once cleared must send focus somewhere that stays. + */ + focusAfterClear?: () => void; + popoverLabel: string; + /** A stable hook, so a filter set elsewhere on the page can move focus to its pill. */ + pillId?: string; + width?: number; + defaultOpen?: boolean; + onOpenChange?: (open: boolean, restoreFocus: boolean) => void; + children: (close: () => void) => ReactNode; +}) { + const popover = usePopover(defaultOpen, onOpenChange); + const clearText = clearLabel ?? `Clear ${label.toLowerCase()} filter`; + return ( + <> +
+ + {active && onClear ? ( + + ) : null} +
+ {popover.open ? ( + popover.close(restoreFocus)}> + {children(() => popover.close(true))} + + ) : null} + + ); +} diff --git a/application/v2_ui/src/lib/activityLogSavedViews.ts b/application/v2_ui/src/lib/activityLogSavedViews.ts new file mode 100644 index 000000000..eab625b94 --- /dev/null +++ b/application/v2_ui/src/lib/activityLogSavedViews.ts @@ -0,0 +1,99 @@ +// activityLogSavedViews.ts +// Named Activity Logs investigations, stored on the administrator's account. +// +// Saved views used to live in this browser's localStorage. They now live in user settings +// (`v2ActivityLogSavedViews`) so they follow the administrator to any browser, following the +// documents explorer's saved views. A view stores the canonical filter query, so a relative +// range such as "last 7 days" stays relative when it is reopened. + +import type { ActivityLogSavedView } from './activityLogs'; + +export const MAX_ACTIVITY_SAVED_VIEWS = 30; +export const MAX_ACTIVITY_VIEW_NAME_LENGTH = 60; +export const MAX_ACTIVITY_VIEW_QUERY_LENGTH = 4096; + +/** The localStorage key the browser-only views used, per signed-in user. */ +export function legacyActivityViewsKey(userId: string): string { + return `simplechat.activity-views.${userId}`; +} + +function newViewId(): string { + const cryptoRef = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined; + if (cryptoRef && typeof cryptoRef.randomUUID === 'function') return cryptoRef.randomUUID(); + return `view-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** Coerce stored or imported values into usable views, dropping malformed entries. */ +export function parseActivitySavedViews(value: unknown): ActivityLogSavedView[] { + if (!Array.isArray(value)) return []; + const views: ActivityLogSavedView[] = []; + const names = new Set(); + const ids = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') continue; + const record = entry as Record; + const name = typeof record.name === 'string' ? record.name.trim().slice(0, MAX_ACTIVITY_VIEW_NAME_LENGTH) : ''; + const query = typeof record.query === 'string' ? record.query.replace(/^\?/, '') : ''; + if (!name || query.length > MAX_ACTIVITY_VIEW_QUERY_LENGTH || names.has(name.toLowerCase())) continue; + let id = typeof record.id === 'string' && record.id.trim() ? record.id.trim() : newViewId(); + if (ids.has(id)) id = newViewId(); + names.add(name.toLowerCase()); + ids.add(id); + views.push({ id, name, query }); + if (views.length >= MAX_ACTIVITY_SAVED_VIEWS) break; + } + return views; +} + +/** Add a view, replacing one with the same name, within the cap. */ +export function upsertActivitySavedView( + views: readonly ActivityLogSavedView[], + name: string, + query: string, +): ActivityLogSavedView[] { + const trimmed = name.trim().slice(0, MAX_ACTIVITY_VIEW_NAME_LENGTH); + if (!trimmed || query.length > MAX_ACTIVITY_VIEW_QUERY_LENGTH) return [...views]; + const index = views.findIndex((view) => view.name.toLowerCase() === trimmed.toLowerCase()); + if (index !== -1) { + const next = [...views]; + next[index] = { ...views[index], name: trimmed, query }; + return next; + } + return [...views, { id: newViewId(), name: trimmed, query }].slice(-MAX_ACTIVITY_SAVED_VIEWS); +} + +export function removeActivitySavedView(views: readonly ActivityLogSavedView[], id: string): ActivityLogSavedView[] { + return views.filter((view) => view.id !== id); +} + +/** Rename a view; a blank name or one another view already uses leaves the list unchanged. */ +export function renameActivitySavedView( + views: readonly ActivityLogSavedView[], + id: string, + name: string, +): ActivityLogSavedView[] { + const trimmed = name.trim().slice(0, MAX_ACTIVITY_VIEW_NAME_LENGTH); + const clash = views.some((view) => view.id !== id && view.name.toLowerCase() === trimmed.toLowerCase()); + if (!trimmed || clash) return [...views]; + return views.map((view) => (view.id === id ? { ...view, name: trimmed } : view)); +} + +/** Merge browser-only views into the account's; an account view wins a name clash. */ +export function mergeImportedActivityViews( + account: readonly ActivityLogSavedView[], + imported: readonly ActivityLogSavedView[], +): ActivityLogSavedView[] { + const names = new Set(account.map((view) => view.name.toLowerCase())); + const additions = imported.filter((view) => !names.has(view.name.toLowerCase())); + return parseActivitySavedViews([...account, ...additions]); +} + +/** Views saved by the browser-only version for this user, or [] when there are none. */ +export function readLegacyActivityViews(storage: Pick, userId: string): ActivityLogSavedView[] { + try { + const raw = storage.getItem(legacyActivityViewsKey(userId)); + return raw ? parseActivitySavedViews(JSON.parse(raw)) : []; + } catch { + return []; + } +} diff --git a/application/v2_ui/src/lib/activityLogs.ts b/application/v2_ui/src/lib/activityLogs.ts new file mode 100644 index 000000000..718ff9be3 --- /dev/null +++ b/application/v2_ui/src/lib/activityLogs.ts @@ -0,0 +1,392 @@ +// activityLogs.ts +// Types, the URL filter contract, date presets and time formatting for Control Center +// Activity Logs. +// +// The URL is the investigation's state, so a bookmark, a Dashboard drill-through or a saved +// view reopens exactly what was on screen. A relative range is stored as `range` (for example +// `range=7`) so a saved "last 7 days" view stays relative; a custom window is stored as +// inclusive UTC `start_date`/`end_date`. The API always receives explicit dates. + +export type ActivityRecord = { + id: string; + timestamp: string; + activity_type?: unknown; + user_id?: unknown; + workspace_type?: unknown; + [key: string]: unknown; +}; + +export interface ActivityPerson { + id: string; + name: string; + email: string; + kind: 'user' | 'system'; + resolved: boolean; +} + +export interface ActivityWorkspaceRef { + type: string; + id: string; + name: string; + resolved: boolean; +} + +export interface ActivityFact { + label: string; + value: string; +} + +/** The server's readable presentation of one record; the table, drawer and export share it. */ +export interface ActivityPresentation { + activity_type: string; + label: string; + category: string; + summary: string; + detail: string; + facts: ActivityFact[]; + status: 'failed' | null; + actor: ActivityPerson; + workspace: ActivityWorkspaceRef; +} + +export interface ActivityFilterLabels { + person?: { id: string; name: string; email: string; resolved: boolean }; + workspace?: ActivityWorkspaceRef; +} + +export interface ActivityPage { + items: ActivityRecord[]; + presentation?: ActivityPresentation[]; + filter_labels?: ActivityFilterLabels; + search_people?: { matched: number; truncated: boolean }; + next_cursor: string | null; + snapshot: string; +} + +export interface ActivityFacet { + activity_type: string; + count: number; + label?: string; + category?: string; +} + +export interface ActivityTypeOption { + activity_type: string; + label: string; + category: string; + category_label: string; +} + +export interface ActivitySummary { + facets: ActivityFacet[]; + histogram: { date: string; count: number }[]; + bucket_days: number; + sample_size: number; + sample_limit: number; + truncated: boolean; + type_catalog?: ActivityTypeOption[]; +} + +export interface ActivityPersonOption { + id: string; + display_name: string; + email: string; +} + +export interface ActivityWorkspaceOption { + type: 'group' | 'public'; + id: string; + name: string; +} + +export const RANGE_PRESETS = [ + { id: 'today', label: 'Today', days: 1 }, + { id: '7', label: 'Last 7 days', days: 7 }, + { id: '30', label: 'Last 30 days', days: 30 }, + { id: '90', label: 'Last 90 days', days: 90 }, +] as const; +export type RangePreset = (typeof RANGE_PRESETS)[number]['id']; +export const DEFAULT_RANGE: RangePreset = '30'; +export const MAX_RANGE_DAYS = 366; + +export type WorkspaceType = '' | 'personal' | 'group' | 'public'; + +export interface ActivityFilters { + /** The relative preset in force, or '' for a custom UTC window. */ + range: RangePreset | ''; + start_date: string; + end_date: string; + activity_type: string[]; + user_id: string; + workspace_type: WorkspaceType; + workspace_id: string; + search: string; + token_type: string; + model: string; + status: string; +} + +export const TEXT_FILTER_KEYS = ['user_id', 'workspace_id', 'search', 'token_type', 'model', 'status'] as const; + +export const TOKEN_TYPES = [ + { value: 'chat', label: 'Chat' }, + { value: 'embedding', label: 'Embedding' }, + { value: 'web_search', label: 'Web search' }, +] as const; + +export const WORKSPACE_TYPE_LABELS: Record = { + personal: 'Personal', + group: 'Group', + public: 'Public', + admin: 'Administration', + global: 'Global', +}; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function todayUtc(now: Date = new Date()): string { + return now.toISOString().slice(0, 10); +} + +export function isUtcDate(value: string): boolean { + if (!DATE_PATTERN.test(value)) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +export function shiftUtcDate(value: string, days: number): string { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +export function daysBetween(start: string, end: string): number { + return Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000); +} + +export function presetDates(range: RangePreset, now: Date = new Date()): { start_date: string; end_date: string } { + const end = todayUtc(now); + const days = RANGE_PRESETS.find((preset) => preset.id === range)?.days ?? 30; + return { start_date: shiftUtcDate(end, -(days - 1)), end_date: end }; +} + +function isRangePreset(value: string | null): value is RangePreset { + return RANGE_PRESETS.some((preset) => preset.id === value); +} + +export function defaultFilters(now: Date = new Date()): ActivityFilters { + return { + range: DEFAULT_RANGE, + ...presetDates(DEFAULT_RANGE, now), + activity_type: [], + user_id: '', + workspace_type: '', + workspace_id: '', + search: '', + token_type: '', + model: '', + status: '', + }; +} + +/** + * Read filters from the URL, accepting every link shape that already points here: the + * Dashboard's single `date`, the group drawer's `group_id`, and the legacy + * `workspace_type=public_workspace`. + */ +export function readFilters(params: URLSearchParams, now: Date = new Date()): ActivityFilters { + const filters = defaultFilters(now); + const range = params.get('range'); + const single = params.get('date') ?? ''; + const start = params.get('start_date') ?? ''; + const end = params.get('end_date') ?? ''; + if (isRangePreset(range)) { + Object.assign(filters, { range }, presetDates(range, now)); + } else if (start || end || single) { + const endDate = end || single || todayUtc(now); + filters.range = ''; + filters.end_date = endDate; + filters.start_date = start || single || (isUtcDate(endDate) ? shiftUtcDate(endDate, -29) : endDate); + } + filters.activity_type = [...new Set(params.getAll('activity_type') + .flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter((value) => value && value !== 'all'))]; + for (const key of TEXT_FILTER_KEYS) filters[key] = (params.get(key) ?? '').trim(); + const rawType = params.get('workspace_type') ?? ''; + const type = rawType === 'public_workspace' ? 'public' : rawType; + filters.workspace_type = (['personal', 'group', 'public'].includes(type) ? type : '') as WorkspaceType; + const groupId = (params.get('group_id') ?? '').trim(); + const publicId = (params.get('public_workspace_id') ?? '').trim(); + if (groupId) { + filters.workspace_type = 'group'; + filters.workspace_id = groupId; + } else if (publicId) { + filters.workspace_type = 'public'; + filters.workspace_id = publicId; + } + if (!filters.workspace_type) filters.workspace_id = ''; + return filters; +} + +function appendShared(params: URLSearchParams, filters: ActivityFilters) { + filters.activity_type.forEach((value) => params.append('activity_type', value)); + if (filters.user_id) params.set('user_id', filters.user_id); + if (filters.workspace_type) params.set('workspace_type', filters.workspace_type); + if (filters.workspace_type && filters.workspace_id) params.set('workspace_id', filters.workspace_id); + for (const key of ['search', 'token_type', 'model', 'status'] as const) { + if (filters[key]) params.set(key, filters[key]); + } +} + +/** The canonical URL for a filter set: the default 30-day range is implied, not written. */ +export function filterParams(filters: ActivityFilters): URLSearchParams { + const params = new URLSearchParams(); + if (filters.range && filters.range !== DEFAULT_RANGE) params.set('range', filters.range); + if (!filters.range) { + params.set('start_date', filters.start_date); + params.set('end_date', filters.end_date); + } + appendShared(params, filters); + return params; +} + +/** The API query: always explicit inclusive UTC dates. */ +export function apiParams(filters: ActivityFilters): URLSearchParams { + const params = new URLSearchParams({ start_date: filters.start_date, end_date: filters.end_date }); + appendShared(params, filters); + return params; +} + +export function rangeValidationError(start: string, end: string): string { + if (!isUtcDate(start) || !isUtcDate(end)) return 'Enter both dates as valid calendar dates.'; + if (end < start) return 'The end date must be on or after the start date.'; + if (daysBetween(start, end) + 1 > MAX_RANGE_DAYS) return `Choose a range of ${MAX_RANGE_DAYS} days or fewer.`; + return ''; +} + +const SHORT_DATE = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); +const LONG_DATE = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); + +/** "Oct 1, 2026" for a UTC calendar date. */ +export function formatUtcDay(value: string, withYear = true): string { + if (!isUtcDate(value)) return value; + return (withYear ? LONG_DATE : SHORT_DATE).format(new Date(`${value}T00:00:00Z`)); +} + +export function dateRangeLabel(filters: ActivityFilters): string { + const preset = RANGE_PRESETS.find((item) => item.id === filters.range); + if (preset) return preset.label; + if (filters.start_date === filters.end_date) return `${formatUtcDay(filters.start_date)} (UTC)`; + const sameYear = filters.start_date.slice(0, 4) === filters.end_date.slice(0, 4); + return `${formatUtcDay(filters.start_date, !sameYear)} – ${formatUtcDay(filters.end_date)} (UTC)`; +} + +export function isDefaultFilters(filters: ActivityFilters): boolean { + return filterParams(filters).toString() === ''; +} + +export function humanize(value: string): string { + const text = value.replace(/[_-]+/g, ' ').trim(); + return text ? text.charAt(0).toUpperCase() + text.slice(1) : ''; +} + +export type ActivityTimeZone = 'local' | 'utc'; +export type ActivityDensity = 'comfortable' | 'compact'; + +export interface ActivityLogPrefs { + timeZone: ActivityTimeZone; + density: ActivityDensity; + showTrend: boolean; +} + +export const DEFAULT_ACTIVITY_LOG_PREFS: ActivityLogPrefs = { + timeZone: 'local', + density: 'comfortable', + showTrend: true, +}; + +/** Stored settings are untrusted: keep only recognized values. */ +export function parseActivityLogPrefs(value: unknown): ActivityLogPrefs { + const stored = value && typeof value === 'object' ? value as Record : {}; + return { + timeZone: stored.timeZone === 'utc' ? 'utc' : 'local', + density: stored.density === 'compact' ? 'compact' : 'comfortable', + showTrend: stored.showTrend !== false, + }; +} + +/** Activity timestamps are UTC; older writers stored them without a zone designator. */ +export function parseActivityTime(value: string): Date | null { + if (!value) return null; + const zoned = /([zZ]|[+-]\d{2}:?\d{2})$/.test(value); + const date = new Date(zoned ? value : `${value}Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + +function relativeTime(date: Date, now: Date): string { + const seconds = Math.round((date.getTime() - now.getTime()) / 1000); + const absolute = Math.abs(seconds); + if (absolute < 45) return 'just now'; + if (absolute < 3600) return RELATIVE.format(Math.round(seconds / 60), 'minute'); + if (absolute < 86_400) return RELATIVE.format(Math.round(seconds / 3600), 'hour'); + if (absolute < 30 * 86_400) return RELATIVE.format(Math.round(seconds / 86_400), 'day'); + return ''; +} + +export function utcStamp(date: Date): string { + return `${date.toISOString().slice(0, 19).replace('T', ' ')} UTC`; +} + +/** The table's time cell: the chosen zone up front, the other one on hover. */ +export function formatActivityTime(value: string, zone: ActivityTimeZone, now: Date = new Date()) { + const date = parseActivityTime(value); + if (!date) return { primary: value || 'Not recorded', secondary: '', title: value, utc: value, local: value }; + const local = new Intl.DateTimeFormat(undefined, { + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', second: '2-digit', + year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric', + }).format(date); + const localLong = new Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(date); + return { + primary: zone === 'utc' ? utcStamp(date) : local, + secondary: relativeTime(date, now), + title: zone === 'utc' ? localLong : utcStamp(date), + utc: utcStamp(date), + local: localLong, + }; +} + +/** The display name for a person, never an empty string. */ +export function personName(person: Pick): string { + if (person.kind === 'system') return 'System'; + return person.name || person.email || (person.id ? 'Unknown user' : 'Unknown'); +} + +export function workspaceName(workspace: ActivityWorkspaceRef): string { + if (workspace.name) return workspace.name; + if (workspace.type === 'group') return 'Unknown group'; + if (workspace.type === 'public') return 'Unknown public workspace'; + return WORKSPACE_TYPE_LABELS[workspace.type] ?? ''; +} + +export function shortId(value: string): string { + return value.length > 14 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value; +} + +/** + * True when a picker's search text is plausibly an ID rather than a name: no spaces or @, + * and a digit or hyphen, as every SimpleChat user and workspace ID has. Used to offer + * filtering by the ID of someone or something no longer in SimpleChat. + */ +export function looksLikeId(value: string): boolean { + return /^[^\s@]{4,256}$/.test(value) && /[\d-]/.test(value); +} + +export interface ActivityLogSavedView { + id: string; + name: string; + /** The canonical filter query string, as written by filterParams. */ + query: string; +} diff --git a/application/v2_ui/src/lib/userSettings.ts b/application/v2_ui/src/lib/userSettings.ts index 8cb806f30..afd6c71dc 100644 --- a/application/v2_ui/src/lib/userSettings.ts +++ b/application/v2_ui/src/lib/userSettings.ts @@ -15,6 +15,7 @@ import type { SidebarMenuState } from './sidebarMenuState'; import type { ApprovalMode } from './orchestration'; import type { DocumentExplorerPrefs, DocumentSavedView } from './types'; +import type { ActivityLogPrefs, ActivityLogSavedView } from './activityLogs'; /** Text scale, matching the values the route normalises to. */ export type FontSizePreference = 'xs' | 's' | 'm' | 'l' | 'xl'; @@ -92,6 +93,13 @@ export interface UserSettings { /** Whether the V2 Control Center section rail is collapsed to icons. */ v2ControlCenterRailCollapsed?: boolean; + /** + * Activity Logs saved views and display preferences. Stored on the account so they follow + * the administrator across browsers; see lib/activityLogSavedViews.ts. + */ + v2ActivityLogSavedViews?: ActivityLogSavedView[]; + v2ActivityLogPrefs?: Partial; + /** * Whether the User Settings sections rail shows icons only. Its own key for the same * reason the admin and workspace rails have theirs. @@ -245,6 +253,9 @@ export const WRITABLE_USER_SETTING_KEYS = [ 'v2UserSettingsRailCollapsed', // Separate from the shell and Admin Settings rails so each keeps its own layout. 'v2ControlCenterRailCollapsed', + // Control Center Activity Logs: saved filter combinations and display preferences. + 'v2ActivityLogSavedViews', + 'v2ActivityLogPrefs', // Workspace documents explorer: how the list is presented, and the saved filter // combinations pinned in its navigation rail. 'v2DocumentsPrefs', diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index d68b41831..8ae6fec72 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -8,7 +8,7 @@ The V2 Control Center is a permission-aware administration pane for managing Sim **Groups implemented in version:** 0.261.282 **Activity Logs implemented in version:** 0.261.284 **Public Workspaces implemented in version:** 0.261.283 -**Current version:** 0.261.292 (Dashboard, Users and Groups query fixes for the Python Cosmos SDK; Data health removed) +**Current version:** 0.261.294 (Activity Logs redesign with names and filter pills; Groups, group details, the Activity Logs group filter and Activity Logs search no longer fail on the reserved `group` keyword) **Dependencies:** React 18, TypeScript, Vite, Flask session authentication, and the existing Control Center APIs. @@ -41,9 +41,9 @@ Dashboard drill-through links set these parameters for the management sections b | Users | `user_id`, `filter`, `status` | | Groups | `id`, `status` | | Public Workspaces | `id`, `status` | -| Activity Logs | `activity_type`, `date`, `start_date`, `end_date`, `workspace_type`, `workspace_id`, `group_id`, `user_id`, `token_type`, `model`, `status` | +| Activity Logs | `activity_type`, `range`, `date`, `start_date`, `end_date`, `workspace_type`, `workspace_id`, `group_id`, `public_workspace_id`, `user_id`, `search`, `token_type`, `model`, `status` | -Section paths are `/control-center/users`, `/control-center/groups`, `/control-center/public-workspaces`, and `/control-center/activity-logs`. IDs and parameter values are URL-encoded. `date` is a UTC calendar date; `start_date` and `end_date` are inclusive UTC dates. +Section paths are `/control-center/users`, `/control-center/groups`, `/control-center/public-workspaces`, and `/control-center/activity-logs`. IDs and parameter values are URL-encoded. `date` is a UTC calendar date; `start_date` and `end_date` are inclusive UTC dates. Activity Logs' `range` is a relative window (`today`, `7`, `30` or `90` days ending today, UTC) that saved views and bookmarks keep relative. ## Users @@ -80,7 +80,7 @@ The Groups section helps administrators find shared workspaces that need attenti | `page`, `per_page` | Server paging, default 25 and maximum 250 rows | | `force_refresh=1` | Rebuild the server inventory instead of using its 90-second cache | -`functions_control_center_groups.py` builds an inventory with four batched Cosmos queries. One projects the groups. The other three stream narrow projections that the application aggregates into document counts, all-time token totals and latest activity timestamps: document metadata group IDs, group token records, and a coalesced group ID with timestamp. The Python Cosmos SDK cannot run cross-partition `GROUP BY`. Activity includes the top-level `group_id`, nested `group.group_id`, and `workspace_context.group_id` writer shapes. Filtering, sorting and paging occur on the server after aggregation; neither the browser nor per-row enrichment performs filtering or counting. This deliberately avoids N+1 queries and Cosmos ordering on undefined/computed fields. Tied sort values use stable ID ordering and missing activity sorts last in either direction. Failed aggregates fail the request rather than silently reporting zero. +`functions_control_center_groups.py` builds an inventory with four batched Cosmos queries. One projects the groups. The other three stream narrow projections that the application aggregates into document counts, all-time token totals and latest activity timestamps: document metadata group IDs, group token records, and a coalesced group ID with timestamp. The Python Cosmos SDK cannot run cross-partition `GROUP BY`. Activity includes the top-level `group_id`, nested `group.group_id`, and `workspace_context.group_id` writer shapes. `GROUP` is a reserved word in Cosmos SQL, so the nested shape is read as `c['group']['group_id']`; the dotted `c.group.group_id` is a syntax error that rejected the whole inventory query until 0.261.294. Filtering, sorting and paging occur on the server after aggregation; neither the browser nor per-row enrichment performs filtering or counting. This deliberately avoids N+1 queries and Cosmos ordering on undefined/computed fields. Tied sort values use stable ID ordering and missing activity sorts last in either direction. Failed aggregates fail the request rather than silently reporting zero. A legacy group document with a non-object owner, non-object member entries or a non-text name is listed with those values treated as missing instead of failing the whole list. List and detail failures log the Cosmos status code with the error type. The response includes `groups`, `pagination`, and `metrics_freshness` with the snapshot's `calculated_at`, source and TTL. List/CSV totals can lag external writes by up to 90 seconds; **Refresh groups** bypasses the cache. Rebuild cost scales with the number of group documents and all-time group token and activity records, while memory holds only per-group totals. This is not continuation-token pagination. Legacy storage-size estimates retain their own older refresh timestamp and are not represented as current counts. @@ -106,17 +106,41 @@ Delete group, delete all documents, take ownership and transfer ownership reuse ## Activity Logs -Implemented in version: **0.261.284**, tracked by `VERSION` in `application/single_app/config.py`. +Implemented in version: **0.261.284**, tracked by `VERSION` in `application/single_app/config.py`. Redesigned in **0.261.294**. -Activity Logs is an investigation surface for administrators with `can_view_activity_logs`. It links dashboard trends, a user's recent activity, and workspace timelines to the same filtered evidence. All three APIs use the existing login-protected Control Center Blueprint, `@swagger_route(security=get_auth_security())`, and `control_center_required('activity_logs')`; dashboard-only readers cannot query or export activity. +Activity Logs is an investigation surface for administrators with `can_view_activity_logs`. It links dashboard trends, a user's recent activity, and workspace timelines to the same filtered evidence, and answers who did what, where and when in human terms: people and workspaces appear by name, and any person, activity type or workspace in a row filters the log to it. All five APIs use the existing login-protected Control Center Blueprint, `@swagger_route(security=get_auth_security())`, and `control_center_required('activity_logs')`; dashboard-only readers cannot query, look up names or export activity. ### Query and paging contract -`GET /api/v2/control-center/activity-logs` accepts inclusive UTC `start_date`/`end_date`, or the dashboard's single-day `date`. The default is the latest 30 UTC dates; ranges are limited to 366 days. Filters include repeated or comma-separated `activity_type` values (OR within types, AND with other filters), `user_id`, `workspace_type`, `workspace_id`, `group_id`, `public_workspace_id`, `search`, `token_type`, `model`, and recorded `status`. Search is a case-insensitive substring across stored IDs, actor emails, names, descriptions, file names, conversation titles and model names, not a full-text index. It is parameterized, limited to 200 characters, and does not trigger profile or Graph enrichment. +`GET /api/v2/control-center/activity-logs` accepts inclusive UTC `start_date`/`end_date`, or the dashboard's single-day `date`. The default is the latest 30 UTC dates; ranges are limited to 366 days. Filters include repeated or comma-separated `activity_type` values (OR within types, AND with other filters), `user_id`, `workspace_type`, `workspace_id`, `group_id`, `public_workspace_id`, `search`, `token_type`, `model`, and recorded `status`. Search is a case-insensitive substring across stored IDs, actor emails, names, descriptions, file names, conversation titles and model names, not a full-text index. It is parameterized, limited to 200 characters, and does not trigger Graph enrichment. -The query recognizes top-level and nested group/public-workspace identifiers and the historical `public_workspace` workspace-type spelling. `workspace_id` requires a workspace type; for personal workspaces it matches the user's ID. User filtering includes the stored partition user and the actor fields used by status/admin writers. `status=failed` matches recorded failure/error text; it does not infer failures from unrecorded events. +Since 0.261.294 search also finds what a person did when the term matches their name or email. When the term has at least two characters, the route looks up to 25 SimpleChat users whose `user_settings` display name or email contains it and adds them to the search as actors. The page, summary and export reuse a term's matches from the same five-minute cache as the names. More than 25 matches sets `search_people.truncated`, and the page suggests the Person filter. The matched IDs widen the search only; they are not part of the cursor's filter scope, so paging continues if a new user matches between pages. A failed people lookup is logged and the search falls back to the stored fields. -Responses contain `items`, `next_cursor`, `page_size`, and `snapshot`. Page size defaults to 50 and is bounded at 200. Paging uses a descending keyset of the **stored timestamp string, ID, and user partition**, not Cosmos continuation tokens, `OFFSET`, or a total-count scan. The partition tie-breaker is required because Cosmos IDs are unique only within a partition. Cursors retain the original timestamp spelling, distinguish missing/null partition values, carry a time cutoff, and reject reuse with different filters. Refresh starts a new sequence. The cutoff excludes newer timestamped events, but is not a Cosmos transactional snapshot: deletion, edits, or late/backdated writes can change an ongoing investigation. Records without string timestamps or IDs, or with malformed non-string/non-null user partitions, cannot participate in this ordered feed; legacy browsing remains available for those records. +The query recognizes top-level and nested group/public-workspace identifiers and the historical `public_workspace` workspace-type spelling. `GROUP` is a reserved word in Cosmos SQL, so the nested `group` object is read as `c['group']`; before 0.261.294 the dotted form made every search and every group filter fail with HTTP 400. `workspace_id` requires a workspace type; for personal workspaces it matches the user's ID. A specific group or public workspace matches every record that references it, whatever workspace type the writer stored, so membership removals and role changes, which record a group but no workspace type, are included. Group references are read from `workspace_context.group_id`, `group_id`, `group.group_id` and `workspace_context.group_workspace_id` (user agreement acceptances). Public workspace references are read from `workspace_context.public_workspace_id`, `public_workspace_id`, `public_workspace.public_workspace_id` (membership removals and access requests), `public_workspace.workspace_id` (status changes) and the bare `workspace_id` that public workspace ownership approvals record. A workspace type on its own (`group` or `public`) matches records of that type or that reference such a workspace. The person filter matches every field where writers record who acted: `user_id`, `admin_user_id`, `requester_id`, `added_by_user_id`, `changed_by_user_id`, `changed_by.user_id`, `removed_by.user_id`, `admin.user_id` and `actor.user_id`. `status=failed` matches recorded failure/error text; it does not infer failures from unrecorded events. + +Responses contain `items`, `presentation`, `filter_labels`, `search_people`, `next_cursor`, `page_size`, and `snapshot`. Page size defaults to 50 and is bounded at 200. Paging uses a descending keyset of the **stored timestamp string, ID, and user partition**, not Cosmos continuation tokens, `OFFSET`, or a total-count scan. The partition tie-breaker is required because Cosmos IDs are unique only within a partition. Cursors retain the original timestamp spelling, distinguish missing/null partition values, carry a time cutoff, and reject reuse with different filters. Refresh starts a new sequence. The cutoff excludes newer timestamped events, but is not a Cosmos transactional snapshot: deletion, edits, or late/backdated writes can change an ongoing investigation. Records without string timestamps or IDs, or with malformed non-string/non-null user partitions, cannot participate in this ordered feed; legacy browsing remains available for those records. + +### Readable presentation and names + +`functions_control_center_activity_display.py` turns each record into the presentation the table, detail drawer and CSV export share, so an activity reads the same everywhere. It has no Flask or Azure dependency; the route passes the containers in. `presentation[i]` describes `items[i]` and contains: + +| Field | Meaning | +|---|---| +| `label`, `category` | A readable activity type ("Token usage") and its group (Sign-in and consent, Chat and conversations, Documents, Token usage, Groups, Public workspaces, Approvals and administration, Agents/actions/workflows, Data and sync, Other). Unknown types are humanized. | +| `summary`, `detail` | What happened, ported from the classic Control Center's per-type formatting: token totals and model, file names, conversation titles, status transitions, members and roles, approval requesters and approvers, file sync counts, data management jobs, agent and workflow runs. | +| `facts` | Label/value pairs for the drawer's Details section. | +| `status` | `failed` when the record's status, document status, run status or error mentions a failure. | +| `actor` | `id`, `name`, `email`, `kind` (`user` or `system`) and `resolved`. The first recorded actor field wins (see the person filter above); `system`/`unknown` IDs and records without an actor read as System. | +| `workspace` | `type`, `id`, current `name` and `resolved`. Falls back to the name recorded on the activity when the workspace no longer exists. | + +Names come from SimpleChat's own `user_settings`, `groups` and `public_workspaces` documents, the same source the classic Control Center used, never from Microsoft Graph. Each page resolves its IDs with at most one parameterized `ARRAY_CONTAINS(@ids, c.id)` query per kind (100 IDs per batch) and keeps results, including misses, in a 5-minute in-process cache of up to 5,000 entries. A lookup failure is logged and the page is returned with IDs instead of names. `filter_labels` carries the names for the current person and workspace filters, so the toolbar never shows a bare ID after a Dashboard, Users or Groups drill-through. + +Two lookups back the filter pickers: + +- `GET /api/v2/control-center/activity-logs/people?q=` returns up to 10 SimpleChat users whose display name or email contains the term, or whose ID equals it, as `{"people": [{"id", "display_name", "email"}]}`. +- `GET /api/v2/control-center/activity-logs/workspaces?q=` returns up to 10 groups and 10 public workspaces whose name contains the term, or whose ID equals it, as `{"workspaces": [{"type", "id", "name"}]}`. + +Terms shorter than two characters return an empty list without a query; terms over 200 characters return 400. Exact ID and exact name matches rank first. These endpoints reveal names and emails only to Control Center administrators, the same population that can already list users and groups. ### Index rollout @@ -124,15 +148,31 @@ New `activity_logs` containers receive the composite index on `/timestamp`, `/id ### Bounded distribution and export -`GET /api/v2/control-center/activity-logs/summary` applies the same filters and projects the newest **at most 5,000** matching records, using one extra projected row to detect truncation. It returns activity-type facets, a UTC histogram with no more than 31 buckets, bucket width, sample size/limit and `truncated`. Facets describe the current filtered result, including selected activity types; they are not disjunctive counts of unselected categories. When truncated, the UI explicitly labels the data as a newest-record sample, not full-range totals. The histogram has an accessible data table and date drill-through. +`GET /api/v2/control-center/activity-logs/summary` applies the same filters, including the people search, and projects the newest **at most 5,000** matching records, using one extra projected row to detect truncation. It returns activity-type facets with their `label` and `category`, a UTC histogram with no more than 31 buckets, bucket width, sample size/limit, `truncated`, and `type_catalog`, the list of labelled activity types the Activity filter offers. Facets describe the current filtered result, including selected activity types; they are not disjunctive counts of unselected categories. When truncated, the UI says the trend and counts use the newest 5,000 records rather than presenting full-range totals. + +`GET /api/v2/control-center/activity-logs/export.csv` streams the same filtered order in page-sized reads, with an upper limit of **10,000 activity rows** and a final `export_limit_reached` status row when the cap is reached. Narrow the filters for a complete larger investigation. The columns are `timestamp`, `id`, `user_id`, `activity_type`, `workspace_type`, `user_name`, `user_email`, `activity`, `summary`, `workspace_id`, `workspace_name` and `raw_json`. The first five and the trailing raw JSON keep their 0.261.284 positions; the readable columns between them come from the same presentation as the table, with names resolved per batch. Cells beginning with `=`, `+`, `-`, `@`, tab or carriage return are apostrophe-prefixed, including whitespace-prefixed formulas. It is an uncached attachment with `X-Export-Row-Limit`. The first storage query runs before response headers; later storage failures log and interrupt the stream rather than producing a success-shaped fallback. + +### Browser behavior + +`ActivityLogsSection.tsx` orchestrates the components in `components/controlCenter/activityLogs/`; `lib/activityLogs.ts` owns the URL contract, date presets and time formatting. From top to bottom the page shows: -`GET /api/v2/control-center/activity-logs/export.csv` streams the same filtered order in page-sized reads, with an upper limit of **10,000 activity rows** and a final `export_limit_reached` status row when the cap is reached. Narrow the filters for a complete larger investigation. The CSV includes timestamp, ID, user ID, activity type, workspace type and JSON; cells beginning with `=`, `+`, `-`, `@`, tab or carriage return are apostrophe-prefixed, including whitespace-prefixed formulas. It is an uncached attachment with `X-Export-Row-Limit`. The first storage query runs before response headers; later storage failures log and interrupt the stream rather than producing a success-shaped fallback. +1. **Header actions:** **Views**, **Refresh** and **Export CSV**. +2. **Filter pills** (`ActivityFilterBar.tsx`, `FilterPill.tsx`): a search field followed by Date, Activity, Person and Workspace pills and **Add filter** (Model, Token type, Recorded status). Each pill shows its current value in human terms, opens its editor in a portalled popover, and applies as soon as it changes; there is no Apply step. Search applies 400 ms after typing stops, or on Enter; the field keeps what is being typed while the URL updates. The Person and Workspace pickers (`EntityCombobox.tsx`) follow the ARIA combobox pattern and search the lookups above, so administrators pick people and workspaces by name or exact ID. When the text looks like an ID (no spaces or `@`, and a digit or hyphen) and nothing in SimpleChat has that ID, the picker offers **Filter by user ID** or **Filter by group ID** / **Filter by public workspace ID**, so activity of someone or something since removed can still be investigated. **Reset filters** appears whenever the filters differ from the default. +3. **Trend strip** (`ActivityTrendStrip.tsx`): the record count and bucket width, one bar per UTC bucket, and the top four activity types with counts. The bars are buttons in a single-tab-stop toolbar: arrow keys, Home and End move between them, and Enter narrows the date range to that bucket. Selecting a top type toggles it as a filter. The strip collapses to its count line. +4. **Log table** (`ActivityTable.tsx`): Time, Person, Activity, Details and Workspace. Selecting a person, activity type or workspace filters by it; selecting the details or the row opens the record. Below the `md` breakpoint the rows stack instead. Loading shows skeleton rows; an empty result offers to widen the range to 90 days or reset the filters. +5. **Detail drawer** (`ActivityDetailDrawer.tsx`): the summary, then Who (with **Show only this person's activity** and **Open in Users**), Where (with **Show only this workspace** and **Open group** or **Open workspace**), Details, Record (raw type, UTC and local time, record ID, approval link) and collapsed raw JSON with **Copy JSON**. **Previous** and **Next** step through the page. -### Browser behavior and validation +The URL is the investigation's state. A relative range is written as `range` (`today`, `7`, `90`; the default 30 days is implied), and a custom window as `start_date`/`end_date`. Existing links keep working: `date`, `group_id`, `public_workspace_id` and `workspace_type=public_workspace` are read and normalized to `workspace_type` and `workspace_id`. The API always receives explicit dates. A relative range re-reads the UTC date whenever a filter changes, on **Refresh**, before **Export CSV**, and when the administrator returns to the tab, so a page left open past UTC midnight does not keep querying the previous day. -`ActivityLogsSection.tsx` keeps applied filters in React Router search parameters, honors dashboard/bookmark drill-through, resets paging on filter changes, cancels stale requests, and provides loading, empty, error/retry and compact/comfortable states. Recent login/token presets start a clean seven-day investigation. Saved views store at most 20 named filter sets in per-user localStorage, on this browser only. Storage failures are visible. Detail drawers show formatted fields and escaped raw JSON, plus recorded user/group/public-workspace links and `/approvals/all/` links with `group_id` for group requests. Router links omit `/v2` because the application basename supplies it. Chart runtime and built assets remain local; no raw HTML rendering is used. +Selecting a person, activity type, workspace or trend bar in the log, or a **Show only** button in the drawer, replaces the rows it was chosen from, so keyboard focus moves to the matching filter pill, which now shows the new value. Clearing a pill keeps focus on it; clearing or cancelling an added filter moves focus to **Add filter**. + +Times show in the browser's local time by default with the UTC time on hover; the **Local time | UTC** choice, row density and the trend strip's visibility are stored in the `v2ActivityLogPrefs` user setting. Saved views are stored on the account in the `v2ActivityLogSavedViews` user setting (up to 30, names up to 60 characters), so they follow the administrator across browsers. A view stores the canonical filter query, so "last 7 days" stays relative. Views saved by earlier versions in this browser's `localStorage` are merged into the account once, by name with the account's copy winning, and the local copy is removed after the save succeeds. While the account's settings are loading, or if they failed to load, the menu cannot save, rename or delete views and the browser copy is left in place, because a change built on views that never loaded would replace the account's real ones. The **Views** menu also offers quick views for recent sign-ins, recent token usage and document processing failures. + +Untrusted text renders as React text; raw JSON is escaped. Router links are built from literal paths with encoded IDs and omit `/v2` because the application basename supplies it. All browser assets remain local. + +### Validation -`functional_tests/test_v2_control_center_activity_logs_queries.py` covers ties across partitions, timestamp spelling, cursor/filter validation, parameter binding, bounded sampling, histogram buckets, streamed export and formula injection. `functional_tests/test_v2_control_center_activity_logs_routes.py` executes the actual handlers and permission decorators, including denial before storage access and generic error handling. The indexing-maintenance regression verifies safe index merging. `ui_tests/test_v2_control_center_activity_logs.py` covers desktop/mobile filters, cursor paging, escaped JSON, related links, keyboard drawer behavior, saved views, export, empty/error recovery and capability gating against built local assets. These isolated checks do not replace a live Cosmos index-transformation/query smoke test. +`functional_tests/test_v2_control_center_activity_logs_queries.py` covers ties across partitions, timestamp spelling, cursor/filter validation, parameter binding, the person filter's actor fields, workspace matching, people-widened search outside the cursor scope, bounded sampling, histogram buckets, streamed export with readable columns and formula injection. Every generated query passes the Cosmos query guard. `functional_tests/test_v2_control_center_activity_display.py` covers labels, per-writer summaries, malformed records, actor and workspace resolution, batched and cached lookups, ranked people and workspace searches, and CSV columns. `functional_tests/test_v2_control_center_activity_logs_routes.py` executes the actual handlers and permission decorators, including denial before storage access, generic errors, degraded name lookups and the two lookup routes. The indexing-maintenance regression verifies safe index merging. `ui_tests/test_v2_control_center_activity_logs.py` covers, against built local assets at desktop and mobile sizes: rows visible in the first viewport, names instead of IDs, cross-filters from rows and the drawer with focus moving to the matching pill, each pill including keyboard pickers and the filter-by-ID option for removed users and workspaces, search text kept while the URL updates, deep links rendered as named pills, keyboard trend drill-through, relative ranges moving to the new UTC day on Refresh and export (using Playwright's clock), the drawer's sections and escaped JSON, the remembered time zone and density, account-backed saved views with the one-time import, saved views locked when settings cannot load, paging, export, empty/error recovery and capability gating. These isolated checks do not replace a live Cosmos query smoke test. ## Public Workspaces @@ -176,8 +216,8 @@ Open **Account → Control Center**, then select a section in its internal rail. ## Testing and limitations -Functional checks cover dashboard status normalization, period deltas, cache expiry and refresh, dashboard-reader access, capability parity, bootstrap exposure, and route wiring. Route-level tests run the real Dashboard and Users handlers, and the Groups inventory, against fake containers that reject query shapes the Python Cosmos SDK cannot run. `functional_tests/test_v2_control_center_cosmos_query_compatibility.py` scans every V2 Control Center SQL string for cross-partition `GROUP BY`, `COUNT` over `DISTINCT` values, and multi-property `ORDER BY` without a declared composite index. Playwright coverage verifies capability-based section visibility and that the removed Data health section stays absent, including for its old URL. Top activity and token rankings are limited to entities present in the recorded `user_id` and workspace-context fields; unlogged historical status snapshots and model/provider details are not inferred. +Functional checks cover dashboard status normalization, period deltas, cache expiry and refresh, dashboard-reader access, capability parity, bootstrap exposure, and route wiring. Route-level tests run the real Dashboard and Users handlers, and the Groups inventory, against fake containers that reject query shapes the Python Cosmos SDK cannot run. `functional_tests/test_v2_control_center_cosmos_query_compatibility.py` scans every V2 Control Center SQL string for cross-partition `GROUP BY`, `COUNT` over `DISTINCT` values, and multi-property `ORDER BY` without a declared composite index. Since 0.261.294 it also rejects reserved keywords (such as `group`, `value` or `order`) used as dotted property names or aliases in every Control Center query, classic routes included, and checks the generated Activity Logs search and group filters, which a static scan cannot see. Playwright coverage verifies capability-based section visibility and that the removed Data health section stays absent, including for its old URL. Top activity and token rankings are limited to entities present in the recorded `user_id` and workspace-context fields; unlogged historical status snapshots and model/provider details are not inferred. ## Version tracking -The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278**, the dashboard in **0.261.279**, user management in **0.261.280**, group management in **0.261.282**, public workspace management in **0.261.283**, and Activity Logs in **0.261.284**. In **0.261.292**, the Dashboard, Users and Groups queries were made compatible with the Python Cosmos SDK. The same release removed the Data health section and its `GET /api/admin/control-center/migrate/status` and `POST /api/admin/control-center/migrate/all` backfill APIs; the classic Control Center had already stopped using them. +The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278**, the dashboard in **0.261.279**, user management in **0.261.280**, group management in **0.261.282**, public workspace management in **0.261.283**, and Activity Logs in **0.261.284**. In **0.261.292**, the Dashboard, Users and Groups queries were made compatible with the Python Cosmos SDK. The same release removed the Data health section and its `GET /api/admin/control-center/migrate/status` and `POST /api/admin/control-center/migrate/all` backfill APIs; the classic Control Center had already stopped using them. In **0.261.294**, the Groups list, group details, the Activity Logs group filter, Activity Logs search and the classic group activity timeline stopped failing on the reserved `group` keyword (see [V2 Control Center Reserved Keyword Query Fix](../fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md)), and Activity Logs was redesigned around filter pills, names and account-backed saved views. diff --git a/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md b/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md index 1a0f4483b..8425dc18f 100644 --- a/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md +++ b/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md @@ -68,3 +68,7 @@ The response contracts are unchanged. Each unsupported query is replaced with a ## Related changes in this release The V2 Data health section was removed in the same release, together with the activity-log backfill APIs that only it used: `GET /api/admin/control-center/migrate/status` and `POST /api/admin/control-center/migrate/all`. See [V2 Control Center](../features/V2_CONTROL_CENTER.md) and [Activity Log Migration Prompt Fix](ACTIVITY_LOG_MIGRATION_PROMPT_FIX.md). + +## Follow-up in 0.261.294 + +Groups still failed after this fix. The inventory's latest-activity query also read the nested `group` object as `c.group.group_id`, and `GROUP` is a reserved Cosmos SQL keyword, so Cosmos rejected the query with the same HTTP 400. See [V2 Control Center Reserved Keyword Query Fix](V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md). diff --git a/docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md b/docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md new file mode 100644 index 000000000..6ccaab18f --- /dev/null +++ b/docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md @@ -0,0 +1,73 @@ +# V2 Control Center Reserved Keyword Query Fix + +**Fixed in version:** 0.261.294 + +## Issue + +After the [V2 Control Center Cosmos Query Compatibility Fix](V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md) in 0.261.292, the V2 Control Center **Groups** section still showed "Unable to retrieve groups." The same root cause also broke four other queries: + +| Where | What the administrator saw | +|---|---| +| `GET /api/v2/control-center/groups` (and `groups/export.csv`, filter-based bulk status) | "Unable to retrieve groups." | +| `GET /api/v2/control-center/groups/` | "Unable to retrieve group details." | +| Activity Logs with a group filter, including **View in Activity Logs** from a group | "Unable to load activity logs. Check the activity-log composite index in App Maintenance, then retry." | +| Activity Logs with **any** search text | The same misleading index message | +| Classic Control Center group activity timeline | No error; member and status-change events were silently missing | + +## Root cause + +`GROUP` is a reserved keyword in the Cosmos DB for NoSQL query language: it begins `GROUP BY`. In the query grammar a dotted property name must be an identifier, and the only keywords allowed there are `ALL`, `FIRST` and `LAST`. A reserved word as a property, such as `c.group.group_id`, is therefore a syntax error. Cosmos rejects the whole query with HTTP 400 before running it, the same status the 0.261.292 investigation attributed to `GROUP BY`. + +Group status-change and member-removal records store their group as a nested `group` object, so several queries read `c.group.group_id`: + +- `load_group_inventory` in `functions_control_center_groups.py`: the coalesced group ID used for each group's latest activity. 0.261.292 removed the query's `GROUP BY` but kept this expression, so the Groups list still failed. +- `api_v2_control_center_group_detail`: projected `c.group` and filtered on `c.group.group_id`. +- `activity_query_context` in `functions_control_center_activity.py`: the Activity Logs group filter, and the search field list, which included `group.group_name` and so generated `CONTAINS(c.group.group_name, ...)` for every search. +- The classic group activity timeline in `route_backend_control_center.py`: its first query failed on every call, and the route's `try`/`except` dropped it silently. + +The tests did not catch it. The query guard only checked for SDK-unsupported shapes, the search clause is generated at run time, and the Groups test asserted the broken text itself. + +## Resolution + +Reserved segments are now written with the quoted property accessor, which Cosmos treats exactly like the dotted form: + +| Before | After | +|---|---| +| `c.group.group_id` | `c['group']['group_id']` (groups inventory, group detail, classic timeline) or `c['group'].group_id` (activity filter) | +| `c.group` in a `SELECT` list | `c['group']`, still returned as `group` | +| `CONTAINS(c.group.group_name, @search, true)` | `CONTAINS(c['group'].group_name, @search, true)` | + +`functions_control_center_activity.py` gains `COSMOS_RESERVED_WORDS` and `cosmos_property_path()`, which brackets any reserved segment of a dotted path. Every generated property reference in the activity query uses it, including the search fields and the new person and workspace filters. + +### Related hardening in the same release + +- `group_members()` and `group_row()` treat a non-object owner, non-object member entries, non-list role lists and non-text names as missing, so one legacy group document cannot turn the whole list into a 500. +- The V2 group list, group detail and activity query failure logs now record the Cosmos `status_code` alongside the error type. Clients still receive only the generic message. + +## Files modified + +- `application/single_app/functions_control_center_groups.py`: `NESTED_GROUP_ID`, the bracketed inventory expression, and tolerant `group_members()`/`group_row()`. +- `application/single_app/functions_control_center_activity.py`: `COSMOS_RESERVED_WORDS`, `cosmos_property_path()`, and the bracketed group filter and search fields. +- `application/single_app/route_backend_control_center.py`: the group detail activity query, the classic group activity timeline query, and status codes in the failure logs. +- `functional_tests/test_support/cosmos_query_guard.py`: `reserved_word_problems()` rejects reserved keywords used as dotted property names or aliases; `cosmos_query_problems()` includes it. +- `functional_tests/test_v2_control_center_cosmos_query_compatibility.py`: applies the reserved-word check to every SQL string in `route_backend_control_center.py` and the `functions_control_center_*` modules, classic routes included, and to the generated Activity Logs search and group filters. +- `functional_tests/test_v2_control_center_groups.py` and `functional_tests/test_v2_control_center_activity_logs_queries.py`: assert the bracketed form, run generated queries through the guard, and add a malformed legacy group case. +- `application/single_app/config.py`: version 0.261.294. + +## Validation + +- `python -m pytest functional_tests/test_v2_control_center_groups.py functional_tests/test_v2_control_center_cosmos_query_compatibility.py functional_tests/test_v2_control_center_activity_logs_queries.py`: all pass. +- With only `functions_control_center_groups.py` reverted, the Groups list, inventory and detail tests fail with "reserved keyword 'group' used as a property name", the guard's report of the production failure. +- The compatibility scan reads more than 60 Control Center query strings and finds no reserved-word property access. + +### Before and after + +| Request | Before | After | +|---|---|---| +| Groups list | HTTP 500, "Unable to retrieve groups." | Groups with document, token and last-activity totals | +| Group details | HTTP 500 | Details with the 20 most recent activity records, including member and status changes | +| Activity Logs search for any text | HTTP 500 with an index hint | Matching records | +| Activity Logs filtered to a group | HTTP 500 with an index hint | That group's activity | +| Classic group activity timeline | Member and status events missing | Complete timeline | + +Existing deployments need no index change or App Maintenance step. diff --git a/docs/guides/v2-control-center.md b/docs/guides/v2-control-center.md index 6838fa062..974d898d0 100644 --- a/docs/guides/v2-control-center.md +++ b/docs/guides/v2-control-center.md @@ -44,13 +44,36 @@ Requesting group deletion, deleting all group documents, taking ownership, or tr ## Investigate activity -Activity Logs was implemented in **0.261.284**. Open it from a dashboard chart, a user/workspace activity link, or the section rail. Choose a UTC date window (default 30 days, maximum 366), select one or more activity-type chips, and narrow by user or workspace ID, model, token type, text or recorded status. **Apply filters** updates the URL so a bookmark preserves the investigation. **More filters** exposes explicit group/public-workspace IDs and status. +Activity Logs was implemented in **0.261.284** and redesigned in **0.261.294**. Open it from a dashboard chart, a user/workspace activity link, or the section rail. It answers who did what, where and when, with people and workspaces shown by name rather than by ID. -The histogram and chip counts describe the filtered records. For busy ranges, they describe only the newest 5,000 matches and clearly say **Sampled**; do not use them as organization-wide totals. Expand the histogram data table to select a UTC bucket and investigate that date window. The table reads 50 records at a time in newest-first order. **Refresh** starts over with newly recorded activity; changing filters resets paging. +### Narrow the log with filter pills -Choose **Inspect** for a record's fields and raw JSON. Related links open its recorded user, group, public workspace or approval. The drawer supports Escape and restores keyboard focus to the opener. Use compact density to scan more rows. Saved views preserve applied filters for the signed-in user on this browser, not across devices; removing a saved view does not delete activity. +The row above the log is the investigation. Each pill shows what it is set to and applies as soon as you change it: -**Export CSV** uses the same filters, not just the visible page, and exports no more than 10,000 activity rows. A final `export_limit_reached` row means the limit was reached; narrow the filters to export a smaller complete range. Spreadsheet formula prefixes are escaped. The export includes raw JSON, so handle the downloaded audit information according to your organization's data policies. +- **Search** matches people's names and emails, file names, conversation titles, models and IDs. Typing a person's name finds what they did, even on records that store only their ID. +- **Date** offers today and the last 7, 30 or 90 days, or a custom UTC range of up to 366 days. The default is the last 30 days. +- **Activity** lists the activity types by group, with counts for the current range. +- **Person** finds a SimpleChat user by name, email or user ID. It shows what that person did, including approvals and admin changes they made. To investigate someone who has since been removed from SimpleChat, type their user ID and choose **Filter by user ID**. +- **Workspace** chooses personal, all groups, all public workspaces, or one group or public workspace by name or ID. A removed workspace can be filtered the same way, with **Filter by group ID** or **Filter by public workspace ID**. +- **Add filter** adds a model, token type or recorded status. + +Remove one filter with the **×** on its pill, or use **Reset filters**. The URL updates as you go, so bookmarking it keeps the investigation, and a relative range such as "Last 7 days" stays relative. If the page stays open past midnight UTC, **Refresh** moves a relative range to the new day. + +### Read and cross-filter the log + +Under the filters, a slim trend strip shows how many records match, one bar per UTC day or period, and the most frequent activity types. Select a bar to narrow the dates to it. With the keyboard, Tab to the bars, move between them with the arrow keys and press Enter. Select an activity type to filter by it. **Hide trend** collapses the strip to its count, and the page remembers your choice. For busy ranges the strip says it uses only the newest 5,000 records; do not read those counts as organization-wide totals. + +Each row reads as a sentence: the time, the person, the activity, what happened and the workspace. Select a person, an activity or a workspace in any row to filter the log by it. Select the details to open the record. Times show in your local time; hover over a time to see it in UTC, or switch the table to **UTC**. **Compact** fits more rows on screen. Both choices are remembered on your account. The log reads 50 records at a time, newest first; **Refresh** starts over with newly recorded activity. + +The record drawer shows what happened, **Who** (with **Show only this person's activity** and **Open in Users**), **Where** (with **Show only this workspace** and the group or workspace), the recorded details, and the record's IDs and approval link. The raw JSON is collapsed at the bottom with **Copy JSON**. Use **Previous** and **Next** to step through the page without closing the drawer. + +### Save and reuse views + +**Views** opens quick views for recent sign-ins, recent token usage and document processing failures, and your saved views. **Save the current filters as** stores the current filters under a name; saving with an existing name replaces that view. Saved views live on your account, so they are there in any browser, and you can rename or delete them from the same menu. Views you saved in a browser before 0.261.294 move to your account the first time you open Activity Logs in that browser. If your settings cannot be loaded, the menu shows only the quick views until you reload the page, so a save cannot overwrite views it could not read. + +### Export + +**Export CSV** uses the same filters, not just the visible page, and exports no more than 10,000 activity rows. Next to the stored IDs, the export includes the person's name and email, the activity, a readable summary, and the workspace name. A final `export_limit_reached` row means the limit was reached; narrow the filters to export a smaller complete range. Spreadsheet formula prefixes are escaped. The export includes raw JSON, so handle the downloaded audit information according to your organization's data policies. On an existing deployment, an indexing error requires an administrator to apply the new expected activity-log composite index in **Admin Settings → App Maintenance** and wait for Cosmos index transformation. The activity page does not automatically apply cloud changes. A date cutoff stabilizes forward paging against newer events, but cannot freeze deletes or late/backdated writes; the feed is not a transactional snapshot. diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index 69003420c..dbb3129f6 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -157,6 +157,8 @@ ("route_backend_control_center.py", "api_v2_control_center_activity_logs"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_activity_summary"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_activity_export"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_activity_people"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_activity_workspaces"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_users"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_user_detail"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_users_bulk_action"): ("login_required", "control_center_required"), diff --git a/functional_tests/test_support/cosmos_query_guard.py b/functional_tests/test_support/cosmos_query_guard.py index f8091bc43..99780a291 100644 --- a/functional_tests/test_support/cosmos_query_guard.py +++ b/functional_tests/test_support/cosmos_query_guard.py @@ -7,6 +7,11 @@ that needs them fails with HTTP 400 before it runs. An ORDER BY over more than one property also fails with HTTP 400 unless the container declares a matching composite index. Fake containers accept any SQL, so tests run their queries through this guard. + +Since 0.261.294 the guard also rejects reserved keywords used as dotted property names or +aliases, such as ``c.group.group_id`` or ``AS value``. The query grammar accepts only ALL, +FIRST and LAST there, so Cosmos answers any other keyword with an HTTP 400 syntax error. The +property must be written with brackets instead: ``c['group']['group_id']``. """ import re @@ -23,6 +28,15 @@ ) ORDER_ITEM_PATTERN = re.compile(r"^(?P.+?)(?:\s+(?PASC|DESC))?$", re.IGNORECASE | re.DOTALL) PROPERTY_PATH_PATTERN = re.compile(r"^c((?:\.[A-Za-z_][A-Za-z0-9_]*)+)$") +# The keyword tokens of the Cosmos SQL grammar, less ALL, FIRST and LAST, which the grammar +# also accepts as identifiers. Each is a syntax error as a dotted property name or alias. +COSMOS_RESERVED_WORDS = frozenset({ + "AND", "ARRAY", "AS", "ASC", "BETWEEN", "BY", "DESC", "DISTINCT", "ESCAPE", "EXISTS", + "FALSE", "FROM", "GROUP", "IN", "JOIN", "LEFT", "LIKE", "LIMIT", "NOT", "NULL", "OFFSET", + "OR", "ORDER", "RANK", "RIGHT", "SELECT", "TOP", "TRUE", "UDF", "UNDEFINED", "VALUE", "WHERE", +}) +DOTTED_NAME_PATTERN = re.compile(r"(?<=[A-Za-z0-9_\]\)])\.(?P[A-Za-z_][A-Za-z0-9_]*)") +ALIAS_PATTERN = re.compile(r"\bAS\s+(?P[A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE) def _split_order_items(items): @@ -66,9 +80,22 @@ def _served_by_composite_index(order_items, composite_indexes): return False +def reserved_word_problems(query): + """Return reserved keywords used as dotted property names or aliases, which never parse.""" + problems = [] + for pattern, kind in ((DOTTED_NAME_PATTERN, "property name"), (ALIAS_PATTERN, "alias")): + for match in pattern.finditer(query): + if match.group("name").upper() in COSMOS_RESERVED_WORDS: + problems.append( + f"reserved keyword '{match.group('name')}' used as a {kind}; " + f"write it with brackets, for example c['{match.group('name')}']" + ) + return list(dict.fromkeys(problems)) + + def cosmos_query_problems(query, composite_indexes=()): """Return why a cross-partition query would be rejected, or an empty list.""" - problems = [] + problems = reserved_word_problems(query) if GROUP_BY_PATTERN.search(query): problems.append("cross-partition GROUP BY is not supported by the Python SDK") if DISTINCT_COUNT_PATTERN.search(query): diff --git a/functional_tests/test_v2_control_center_activity_display.py b/functional_tests/test_v2_control_center_activity_display.py new file mode 100644 index 000000000..2b90a9773 --- /dev/null +++ b/functional_tests/test_v2_control_center_activity_display.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# test_v2_control_center_activity_display.py +""" +Functional tests for the V2 Activity Logs presentation: labels, summaries and names. +Version: 0.261.294 +Implemented in: 0.261.294 + +The V2 Activity Logs table showed raw user and group IDs, so administrators could not tell +who did what or filter by a person they knew by name. The classic Control Center resolved +names from user_settings. These tests run the real, dependency-neutral display module +against fake containers: per-writer summaries, actor and workspace resolution, batched and +cached name lookups, the person and workspace pickers' searches, and CSV columns. +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +sys.path.insert(0, str(ROOT / "functional_tests")) + +from test_support.cosmos_query_guard import assert_cosmos_query_supported +from test_support.versioning import assert_app_version_at_least + + +def _load(name, filename): + spec = importlib.util.spec_from_file_location(name, APP / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +display = _load("activity_display", "functions_control_center_activity_display.py") +activity = _load("activity_queries_for_display", "functions_control_center_activity.py") + + +class Container: + """Answers name lookups and searches from in-memory documents, recording every query.""" + + def __init__(self, documents): + self.documents = {document["id"]: document for document in documents} + self.queries = [] + + def query_items(self, query, parameters, enable_cross_partition_query): + assert enable_cross_partition_query is True + assert_cosmos_query_supported(query) + params = {item["name"]: item["value"] for item in parameters} + self.queries.append((query, params)) + if "ARRAY_CONTAINS(@ids, c.id)" in query: + return iter([doc for doc_id, doc in self.documents.items() if doc_id in params["@ids"]]) + term = params["@term"].casefold() + fields = ("display_name", "email") if "c.display_name" in query else ("name",) + matches = [ + doc for doc in self.documents.values() + if doc["id"] == params["@term"] or any(term in str(doc.get(field, "")).casefold() for field in fields) + ] + return iter(matches[:params["@limit"]]) + + +def test_catalog_labels_categories_and_unknown_types(): + assert display.activity_label("token_usage") == "Token usage" + assert display.activity_category("group_member_deleted") == "groups" + assert display.activity_label("brand_new_event") == "Brand new event" + assert display.activity_category("brand_new_event") == "other" + assert display.activity_label(None) == "Unknown activity" + catalog = display.activity_type_catalog() + assert len(catalog) == len(display.ACTIVITY_TYPES) + order = list(display.ACTIVITY_CATEGORY_LABELS) + assert [order.index(item["category"]) for item in catalog] == sorted(order.index(item["category"]) for item in catalog) + assert all(item["category_label"] == display.ACTIVITY_CATEGORY_LABELS[item["category"]] for item in catalog) + facets = display.label_activity_facets([{"activity_type": "user_login", "count": 4}]) + assert facets == [{"activity_type": "user_login", "count": 4, "label": "User login", "category": "sign_in"}] + + +@pytest.mark.parametrize("record, summary, detail, facts, status", [ + ({"activity_type": "user_login", "login_method": "azure_ad"}, "Signed in", "Method: azure_ad", + {"Login method": "azure_ad"}, None), + ({"activity_type": "chat_activity", "message_type": "user_message", "chat_context": "personal", + "message_length": 1234, "conversation_id": "conv-1", "has_document_search": True, + "additional_context": {"conversation_source": "standard_chat", "agent_name": "Helper"}}, + "User message · Standard chat · Personal", "1,234 characters", + {"Conversation ID": "conv-1", "Document search": "Yes", "Agent": "Helper"}, None), + ({"activity_type": "conversation_creation", "conversation": {"title": "Q3 plan", "conversation_id": "c1", + "tags": ["finance", 3, None]}}, + "Q3 plan", "finance, 3", {"Conversation ID": "c1"}, None), + ({"activity_type": "document_creation", "document": {"file_name": "report.pdf", "file_type": ".pdf", + "file_size_bytes": 2048, "page_count": 12, + "status": "Processing failed: timeout"}}, + "report.pdf", "PDF · 2.0 KB · 12 pages", {"Pages": "12", "Processing status": "Processing failed: timeout"}, + "failed"), + ({"activity_type": "document_metadata_update", "document": {"file_name": "a.docx"}, + "updated_fields": {"title": "x", "authors": []}}, "a.docx", "Updated: authors, title", {}, None), + ({"activity_type": "token_usage", "token_type": "chat", + "usage": {"total_tokens": 12345, "model": "gpt-4o", "prompt_tokens": 12000, "completion_tokens": 345}}, + "12,345 tokens · gpt-4o", "Chat · prompt 12,000 · completion 345", {"Model": "gpt-4o"}, None), + ({"activity_type": "token_usage", "token_type": "embedding", "usage": {"total_tokens": 900}, + "embedding_details": {"file_name": "notes.txt"}}, "900 tokens", "Embedding · notes.txt", + {"File": "notes.txt"}, None), + ({"activity_type": "group_status_change", "group": {"group_id": "g1", "group_name": "Research"}, + "status_change": {"old_status": "active", "new_status": "upload_disabled", "reason": "Audit"}}, + "Active → Upload disabled", "Audit", {"Reason": "Audit", "New status": "upload_disabled"}, None), + ({"activity_type": "group_member_deleted", "removed_member": {"name": "Bo", "email": "bo@example.test"}, + "group": {"group_id": "g1", "group_name": "Research"}}, "Removed Bo", "Research", + {"Member": "Bo", "Member email": "bo@example.test"}, None), + ({"activity_type": "update_member_role", "member_name": "Cy", "old_role": "User", "new_role": "Admin", + "group_id": "g1", "group_name": "Research"}, "Cy: User → Admin", "Research", {"New role": "Admin"}, None), + ({"activity_type": "public_add_member_directly", "member_name": "Di", "member_role": "Admin", + "public_workspace_id": "p1", "public_workspace_name": "Library"}, "Added Di", "Role: Admin · Library", + {"Role": "Admin"}, None), + ({"activity_type": "delete_all_documents_approved", "description": "All documents deleted", + "requester_email": "req@example.test", "approver_email": "ok@example.test", "documents_deleted": 7, + "approval_id": "ap-1"}, "All documents deleted", "Requested by req@example.test · approved by ok@example.test", + {"Documents deleted": "7", "Approval ID": "ap-1"}, None), + ({"activity_type": "file_sync", "action": "sync_completed", "workspace_context": {"source_name": "SharePoint"}, + "additional_context": {"counts": {"scanned": 10, "failed": 0, "queued": 2}}}, + "Sync completed · SharePoint", "Scanned 10 · Queued 2 · Failed 0", {"Source": "SharePoint"}, None), + ({"activity_type": "data_management", "action": "job_finished", + "additional_context": {"operation": "backup", "status": "failed", "job_id": "job-9"}}, + "Job finished · Backup", "Failed · Job job-9", {"Job ID": "job-9"}, "failed"), + ({"activity_type": "workflow_run", "entity": {"name": "Nightly"}, "workspace_type": "personal", + "run": {"status": "failed", "trigger_source": "schedule", "error": "Timeout"}}, + "Nightly", "Personal · Failed · Schedule", {"Error": "Timeout"}, "failed"), + ({"activity_type": "agent_run", "agent": {"display_name": "Analyst"}, "workspace_type": "group", + "model_deployment_name": "gpt-4o"}, "Analyst", "Group · gpt-4o", {"Model": "gpt-4o"}, None), + ({"activity_type": "governance", "action": "policy_updated", "workspace_context": {"scope": "model", "target_id": "m1"}}, + "Policy updated", "Model · m1", {"Target": "m1"}, None), + ({"activity_type": "brand_new_event", "description": "Something happened"}, "Something happened", "", {}, None), +]) +def test_descriptions_for_each_writer_shape(record, summary, detail, facts, status): + view = display.describe_activity(record) + assert view["summary"] == summary + assert view["detail"] == detail + recorded = {fact["label"]: fact["value"] for fact in view["facts"]} + for label, value in facts.items(): + assert recorded.get(label) == value, (label, recorded) + assert view["status"] == status + assert view["label"] == display.activity_label(record["activity_type"]) + assert all(isinstance(fact["value"], str) and fact["value"] for fact in view["facts"]) + + +def test_malformed_records_never_raise(): + for record in (None, [], {"activity_type": 7}, {"activity_type": "document_creation", "document": "x"}, + {"activity_type": "token_usage", "usage": [1, 2]}, + {"activity_type": "chat_activity", "additional_context": "bad", "message_length": "long"}, + {"activity_type": "group_status_change", "status_change": None, "group": "Research"}): + view = display.present_activity_record(record, display.empty_activity_names()) + assert view["summary"] and view["actor"]["kind"] in {"user", "system"} + + +def test_actor_resolution_order_and_system_actors(): + assert display.activity_actor({"user_id": "u1", "admin_user_id": "a1"})["id"] == "u1" + assert display.activity_actor({"changed_by": {"user_id": "a2", "email": "a2@example.test"}}) == { + "id": "a2", "email": "a2@example.test", "kind": "user"} + assert display.activity_actor({"requester_id": "r1", "requester_email": "r@example.test"})["id"] == "r1" + assert display.activity_actor({"removed_by": {"user_id": "x1"}})["id"] == "x1" + assert display.activity_actor({"user_id": "system"}) == {"id": "", "email": "", "kind": "system"} + assert display.activity_actor({"user_id": "system", "admin_email": "ops@example.test"})["kind"] == "user" + assert display.activity_actor({})["kind"] == "system" + + +def test_actor_fields_match_the_person_filter(): + """The Person column and the Person filter must agree on who acted.""" + dotted = tuple(".".join(path) for path in display.ACTOR_ID_PATHS) + assert dotted == activity.ACTIVITY_ACTOR_FIELDS + + +def test_workspace_resolution_covers_every_writer_shape(): + assert display.activity_workspace({"workspace_context": {"group_id": "g1"}, "workspace_type": "group"}) == { + "type": "group", "id": "g1", "recorded_name": ""} + assert display.activity_workspace({"group": {"group_id": "g2", "group_name": "Ops"}})["recorded_name"] == "Ops" + assert display.activity_workspace({"group_id": "g3", "group_name": "Lab"})["id"] == "g3" + status = display.activity_workspace({"workspace_type": "public_workspace", + "public_workspace": {"workspace_id": "p1", "workspace_name": "Library"}, + "workspace_context": {"public_workspace_id": "p1"}}) + assert status == {"type": "public", "id": "p1", "recorded_name": "Library"} + assert display.activity_workspace({"workspace_type": "personal"})["type"] == "personal" + assert display.activity_workspace({"workspace_type": "admin"})["type"] == "admin" + assert display.activity_workspace({})["type"] == "" + # Public membership audits (removed, requested, canceled) nest the workspace this way. + removed = display.activity_workspace({"activity_type": "public_member_removed", + "public_workspace": {"public_workspace_id": "p2", + "public_workspace_name": "Archive"}}) + assert removed == {"type": "public", "id": "p2", "recorded_name": "Archive"} + # Public workspace ownership approvals store only a bare workspace_id. + owner = display.activity_workspace({"type": "workspace_ownership_change", + "activity_type": "transfer_ownership_approved", + "workspace_id": "p3", "workspace_name": "Docs"}) + assert owner == {"type": "public", "id": "p3", "recorded_name": "Docs"} + # Group user agreements store workspace_context.group_workspace_id. + agreement = display.activity_workspace({"workspace_type": "group", + "workspace_context": {"group_workspace_id": "g9", "workspace_name": None}}) + assert agreement == {"type": "group", "id": "g9", "recorded_name": ""} + # A bare workspace_id on a record that says it is a group is not read as a public workspace. + assert display.activity_workspace({"workspace_type": "group", "workspace_id": "x"}) == { + "type": "group", "id": "", "recorded_name": ""} + + +def test_workspace_locations_match_the_workspace_filters(): + """A workspace shown in a row must be one its Workspace filter matches.""" + assert [".".join(path) for path in display.GROUP_ID_PATHS] == list(activity.GROUP_REFERENCE_FIELDS) + assert [".".join(path) for path in display.PUBLIC_ID_PATHS] + ["workspace_id"] == list( + activity.PUBLIC_REFERENCE_FIELDS) + + +def test_name_lookups_are_batched_cached_and_mark_missing_entries(): + clock = [100.0] + users = Container([{"id": "u1", "display_name": "Ada Admin", "email": "ada@example.test"}]) + groups = Container([{"id": "g1", "name": "Research (renamed)"}]) + public = Container([]) + cache = display.ActivityNameCache(ttl_seconds=60, max_entries=10, clock=lambda: clock[0]) + records = [ + {"id": "1", "user_id": "u1", "workspace_context": {"group_id": "g1"}, "workspace_type": "group"}, + {"id": "2", "user_id": "u-gone", "group": {"group_id": "g1", "group_name": "Research"}}, + {"id": "3", "changed_by": {"user_id": "u1"}, "workspace_context": {"public_workspace_id": "p-gone"}, + "workspace_type": "public_workspace", "public_workspace": {"workspace_name": "Old library"}}, + ] + selected = activity.parse_activity_filters({"user_id": "u-filter", "workspace_type": "group", "workspace_id": "g1"}) + kwargs = {"user_container": users, "groups_container": groups, "public_container": public, "cache": cache} + names = display.resolve_activity_names(records, selected, **kwargs) + assert len(users.queries) == len(groups.queries) == len(public.queries) == 1 + assert sorted(users.queries[0][1]["@ids"]) == ["u-filter", "u-gone", "u1"] + assert names["people"]["u1"] == {"display_name": "Ada Admin", "email": "ada@example.test"} + assert names["people"]["u-gone"] is None + views = display.present_activity_rows(records, names) + assert views[0]["actor"] == {"id": "u1", "name": "Ada Admin", "email": "ada@example.test", + "kind": "user", "resolved": True} + assert views[0]["workspace"] == {"type": "group", "id": "g1", "name": "Research (renamed)", "resolved": True} + assert views[1]["actor"]["resolved"] is False and views[1]["actor"]["name"] == "" + assert views[2]["workspace"] == {"type": "public", "id": "p-gone", "name": "Old library", "resolved": False} + labels = display.activity_filter_labels(selected, names) + assert labels["person"] == {"id": "u-filter", "name": "", "email": "", "resolved": False} + assert labels["workspace"] == {"type": "group", "id": "g1", "name": "Research (renamed)", "resolved": True} + display.resolve_activity_names(records, selected, **kwargs) + assert len(users.queries) == len(groups.queries) == len(public.queries) == 1 + clock[0] += 61 + display.resolve_activity_names(records[:1], None, **kwargs) + assert len(users.queries) == 2 and len(groups.queries) == 2 + for index in range(20): + cache.set("person", f"extra-{index}", None) + assert len(cache.entries) == 10 + + +def test_lookups_split_large_pages_into_batches(): + users = Container([{"id": f"u{index}", "display_name": f"User {index}"} for index in range(150)]) + records = [{"id": str(index), "user_id": f"u{index}"} for index in range(150)] + names = display.resolve_activity_names(records, user_container=users, groups_container=Container([]), + public_container=Container([])) + assert [len(params["@ids"]) for _, params in users.queries] == [100, 50] + assert names["people"]["u149"]["display_name"] == "User 149" + + +def test_csv_columns_use_the_presentation(): + view = display.present_activity_record( + {"activity_type": "token_usage", "user_id": "u1", "usage": {"total_tokens": 5, "model": "m"}, + "token_type": "chat", "workspace_context": {"group_id": "g1"}, "workspace_type": "group"}, + {"people": {"u1": {"display_name": "Ada", "email": "ada@example.test"}}, "groups": {"g1": "Research"}, + "public_workspaces": {}}, + ) + assert display.activity_csv_columns(view) == ( + "Ada", "ada@example.test", "Token usage", "5 tokens · m (Chat)", "g1", "Research") + + +def test_people_and_workspace_searches_are_parameterized_ranked_and_bounded(): + users = Container([ + {"id": "u2", "display_name": "Janet Smith", "email": "janet@example.test"}, + {"id": "u1", "display_name": "Jane Doe", "email": "jane@example.test"}, + {"id": "u3", "display_name": "Mary-Jane Ray", "email": "mj@example.test"}, + ]) + assert display.search_activity_people(users, "j") == [] + assert users.queries == [] + people = display.search_activity_people(users, "jane") + assert [person["id"] for person in people] == ["u1", "u2", "u3"] + query, params = users.queries[-1] + assert params == {"@limit": display.ACTIVITY_LOOKUP_LIMIT, "@term": "jane"} and "jane" not in query + assert [person["id"] for person in display.search_activity_people(users, "u3")] == ["u3"] + exact = display.search_activity_people(users, "jane@example.test") + assert exact[0]["id"] == "u1" + many = Container([{"id": f"p{index}", "display_name": f"Pat {index}"} for index in range(40)]) + ids, truncated = display.search_activity_people_ids(many, "pat") + assert len(ids) == display.ACTIVITY_SEARCH_PEOPLE_MAX and truncated is True + assert display.search_activity_people_ids(many, " ") == ([], False) + groups = Container([{"id": "g1", "name": "Finance"}, {"id": "g2", "name": "Finance archive"}]) + public = Container([{"id": "p1", "name": "Finance library"}]) + found = display.search_activity_workspaces(groups, public, "finance") + assert [(item["type"], item["id"]) for item in found] == [("group", "g1"), ("group", "g2"), ("public", "p1")] + assert display.search_activity_workspaces(groups, public, "p1") == [{"type": "public", "id": "p1", "name": "Finance library"}] + + +def test_version_is_at_least_the_implementation_version(): + assert_app_version_at_least("0.261.294") diff --git a/functional_tests/test_v2_control_center_activity_logs_queries.py b/functional_tests/test_v2_control_center_activity_logs_queries.py index 661f9b5d1..75ecb41c1 100644 --- a/functional_tests/test_v2_control_center_activity_logs_queries.py +++ b/functional_tests/test_v2_control_center_activity_logs_queries.py @@ -1,17 +1,21 @@ # test_v2_control_center_activity_logs_queries.py """ Functional tests for bounded Control Center activity queries, paging and export. -Version: 0.261.285 +Version: 0.261.294 Implemented in: 0.261.284 Executes the actual dependency-neutral helper module with a query-contract storage fake. No cloud calls, Flask bootstrap replacement, or production module mutations. +Since 0.261.294 every generated query also passes the Cosmos query guard, which rejects +reserved keywords used as dotted property names: the search field group.group_name made +every Activity Logs search fail with an HTTP 400 syntax error. """ import base64 import csv import importlib.util import json +import sys from datetime import datetime, timezone from io import StringIO from pathlib import Path @@ -21,10 +25,14 @@ ROOT = Path(__file__).resolve().parents[1] APP = ROOT / "application" / "single_app" +sys.path.insert(0, str(ROOT / "functional_tests")) SPEC = importlib.util.spec_from_file_location("activity_queries", APP / "functions_control_center_activity.py") activity = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(activity) NOW = datetime(2026, 10, 7, 14, tzinfo=timezone.utc) +ACTIVITY_INDEXES = [[(entry["path"], entry["order"]) for entry in activity.ACTIVITY_COMPOSITE_INDEX]] + +from test_support.cosmos_query_guard import assert_cosmos_query_supported, reserved_word_problems def filters(**values): @@ -50,6 +58,7 @@ def __init__(self, rows): def query_items(self, **kwargs): self.calls.append(kwargs) query = kwargs["query"] + assert_cosmos_query_supported(query, ACTIVITY_INDEXES) assert query.startswith("SELECT TOP @limit ") assert query.endswith(activity.ACTIVITY_ORDER) assert "OFFSET" not in query and "COUNT" not in query @@ -177,13 +186,75 @@ def test_filters_match_dashboard_contract_and_bind_every_value(): assert params["@search"] == "' OR true --" assert params["@end"] == "2026-10-02" assert "OR true" not in where - assert "c.changed_by.user_id" in where - assert "c.group.group_id" in where and "c.workspace_context.group_id" in where + assert "c.changed_by.user_id = @user" in where and "c.admin_user_id = @user" in where + assert "c['group'].group_id = @group" in where and "c.workspace_context.group_id = @group" in where + assert "c.group." not in where assert "c.document.status" in where assert "c.usage.model = @model" in where + assert not reserved_word_problems(where) public_where, public_params = activity.activity_query_context(filters(workspace_type="public_workspace", workspace_id="pub")) - assert "c.workspace_type IN ('public', 'public_workspace')" in public_where assert {"name": "@public", "value": "pub"} in public_params + assert "c.workspace_type" not in public_where + public_type_where, public_type_params = activity.activity_query_context(filters(workspace_type="public")) + assert "c.workspace_type IN ('public', 'public_workspace')" in public_type_where + assert "IS_STRING(c.workspace_context.public_workspace_id)" in public_type_where + assert not [item for item in public_type_params if item["name"] == "@workspace_type"] + + +def test_person_filter_matches_every_actor_field(): + """Approvals, membership and status records name the actor outside the partition key.""" + where, parameters = activity.activity_query_context(filters(user_id="person-1")) + for path in ("c.user_id", "c.admin_user_id", "c.requester_id", "c.added_by_user_id", + "c.changed_by_user_id", "c.changed_by.user_id", "c.removed_by.user_id", + "c.admin.user_id", "c.actor.user_id"): + assert f"{path} = @user" in where + assert [item["value"] for item in parameters if item["name"] == "@user"] == ["person-1"] + + +def test_a_specific_workspace_matches_records_without_a_workspace_type(): + """Member removals and role changes record the group but no workspace_type.""" + where, parameters = activity.activity_query_context( + filters(workspace_type="group", workspace_id="group-1", group_id="group-1")) + assert "c.workspace_type" not in where + assert "c.group_id = @group" in where and "c['group'].group_id = @group" in where + assert "c.workspace_context.group_workspace_id = @group" in where + assert [item["value"] for item in parameters if item["name"] == "@group"] == ["group-1"] + type_only, _ = activity.activity_query_context(filters(workspace_type="group")) + assert "(c.workspace_type = 'group' OR IS_STRING(c.workspace_context.group_id)" in type_only + assert "IS_STRING(c['group'].group_id)" in type_only + public, _ = activity.activity_query_context(filters(workspace_type="public", workspace_id="p1")) + for path in ("c.workspace_context.public_workspace_id", "c.public_workspace_id", + "c.public_workspace.public_workspace_id", "c.public_workspace.workspace_id", "c.workspace_id"): + assert f"{path} = @public" in public, path + public_type_only, _ = activity.activity_query_context(filters(workspace_type="public")) + assert "IS_STRING(c.public_workspace.public_workspace_id)" in public_type_only + assert not reserved_word_problems(public) and not reserved_word_problems(public_type_only) + personal, personal_params = activity.activity_query_context(filters(workspace_type="personal", workspace_id="u1")) + assert "c.workspace_type = @workspace_type" in personal and "c.user_id = @personal" in personal + assert {"name": "@personal", "value": "u1"} in personal_params + + +def test_search_matches_people_without_changing_the_cursor_scope(): + selected = filters(search="jane") + plain, plain_params = activity.activity_query_context(selected) + assert "@search_people" not in plain and not [p for p in plain_params if p["name"] == "@search_people"] + widened, params = activity.activity_query_context(selected, search_user_ids=["u-jane", "u-janet"]) + assert "ARRAY_CONTAINS(@search_people, c.user_id)" in widened + assert "ARRAY_CONTAINS(@search_people, c.changed_by.user_id)" in widened + assert {"name": "@search_people", "value": ["u-jane", "u-janet"]} in params + assert "CONTAINS(c['group'].group_name, @search, true)" in widened + assert not reserved_word_problems(widened) + ignored, ignored_params = activity.activity_query_context(filters(), search_user_ids=["u-jane"]) + assert "@search_people" not in ignored and not [p for p in ignored_params if p["name"] == "@search_people"] + rows = [{"id": f"r{index}", "timestamp": f"2026-10-01T12:00:0{index}", "user_id": "u-jane", + "activity_type": "user_login"} for index in range(3)] + container = QueryContainer(rows) + first = activity.activity_page(container, selected, page_size=1, search_user_ids=["u-jane"]) + second = activity.activity_page(container, selected, page_size=1, cursor_value=first["next_cursor"], + search_user_ids=["u-jane", "u-new"]) + assert second["items"] and second["items"][0]["id"] != first["items"][0]["id"] + assert all({"name": "@search_people", "value": ids} in call["parameters"] + for call, ids in zip(container.calls, (["u-jane"], ["u-jane", "u-new"]))) @pytest.mark.parametrize("values", [ @@ -237,11 +308,14 @@ def test_streamed_csv_reuses_filters_pages_and_caps_records(monkeypatch): first, snapshot = activity.query_activity_rows(container, selected, 2) stream = activity.activity_csv_stream(container, selected, first, snapshot) header = next(stream) - assert header.startswith("timestamp,id,") + assert header.strip() == ",".join(activity.ACTIVITY_EXPORT_HEADER) + assert header.startswith("timestamp,id,user_id,activity_type,workspace_type,") + assert header.strip().endswith(",raw_json") assert len(container.calls) == 1 contents = header + "".join(stream) exported = list(csv.reader(StringIO(contents))) assert len(exported) == 6 + assert all(len(row) == len(activity.ACTIVITY_EXPORT_HEADER) for row in exported) assert all(row[2] == "'=1" for row in exported[1:-1]) assert exported[-1][3] == "export_limit_reached" assert len({row[1] for row in exported[1:-1]}) == 4 @@ -249,6 +323,34 @@ def test_streamed_csv_reuses_filters_pages_and_caps_records(monkeypatch): assert json.loads(exported[1][-1])["user_id"] == "=1" +def test_csv_readable_columns_come_from_the_presenter_and_are_formula_safe(): + rows = [{"id": "a", "timestamp": "2026-10-01T12:00:01", "user_id": "u1", "activity_type": "token_usage", + "workspace_type": "group"}, + {"id": "b", "timestamp": "2026-10-01T12:00:00", "user_id": "u2", "activity_type": "user_login"}] + container = QueryContainer(rows) + selected = filters(search="ada") + first, snapshot = activity.query_activity_rows(container, selected, 200, search_user_ids=["u1"]) + batches = [] + + def present(batch): + batches.append([row["id"] for row in batch]) + return [("=Ada", "ada@example.test", "Token usage", "120 tokens · gpt", "group-1", "Research") + if row["id"] == "a" else ("", "", "User login", "Signed in", "", "") for row in batch] + + contents = "".join(activity.activity_csv_stream( + container, selected, first, snapshot, search_user_ids=["u1"], present_rows=present, + )) + exported = list(csv.reader(StringIO(contents))) + assert batches == [["a", "b"]] + by_id = {row[1]: dict(zip(activity.ACTIVITY_EXPORT_HEADER, row)) for row in exported[1:]} + assert by_id["a"]["user_name"] == "'=Ada" and by_id["a"]["workspace_name"] == "Research" + assert by_id["a"]["summary"] == "120 tokens · gpt" and by_id["b"]["activity"] == "User login" + assert json.loads(by_id["a"]["raw_json"])["id"] == "a" + broken = "".join(activity.activity_csv_stream(container, selected, first, snapshot, present_rows=lambda batch: [])) + assert all(len(row) == len(activity.ACTIVITY_EXPORT_HEADER) for row in csv.reader(StringIO(broken))) + assert all({"name": "@search_people", "value": ["u1"]} in call["parameters"] for call in container.calls[:1]) + + def test_new_container_and_maintenance_index_contract(): import ast tree = ast.parse((APP / "config.py").read_text(encoding="utf-8")) diff --git a/functional_tests/test_v2_control_center_activity_logs_routes.py b/functional_tests/test_v2_control_center_activity_logs_routes.py index b4f4b8f88..53d532e06 100644 --- a/functional_tests/test_v2_control_center_activity_logs_routes.py +++ b/functional_tests/test_v2_control_center_activity_logs_routes.py @@ -1,18 +1,22 @@ # test_v2_control_center_activity_logs_routes.py """ Functional tests for the Activity Logs HTTP and capability contracts. -Version: 0.261.285 +Version: 0.261.294 Implemented in: 0.261.284 Registers the actual new handlers with real authentication/capability decorators, isolating unrelated Azure bootstrap. Checks authorization before query execution. +Since 0.261.294 the page returns readable presentation and names for its filters, search +also matches people, and two lookups back the person and workspace filters. """ import ast +import csv import importlib.util import logging from functools import wraps from importlib.metadata import version +from io import StringIO from pathlib import Path import pytest @@ -21,13 +25,48 @@ ROOT = Path(__file__).resolve().parents[1] APP = ROOT / "application" / "single_app" -SPEC = importlib.util.spec_from_file_location("activity_route_queries", APP / "functions_control_center_activity.py") -activity = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(activity) + + +def _load(name, filename): + spec = importlib.util.spec_from_file_location(name, APP / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +activity = _load("activity_route_queries", "functions_control_center_activity.py") +display = _load("activity_route_display", "functions_control_center_activity_display.py") PATHS = ("/api/v2/control-center/activity-logs", "/api/v2/control-center/activity-logs/summary", "/api/v2/control-center/activity-logs/export.csv") +LOOKUP_PATHS = ("/api/v2/control-center/activity-logs/people?q=ada", + "/api/v2/control-center/activity-logs/workspaces?q=res") HANDLERS = {"api_v2_control_center_activity_logs", "api_v2_control_center_activity_summary", - "api_v2_control_center_activity_export"} + "api_v2_control_center_activity_export", "api_v2_control_center_activity_people", + "api_v2_control_center_activity_workspaces"} +HELPERS = {"_activity_search_people", "_activity_names", "_activity_export_columns"} +RECORD = {"id": "r1", "timestamp": "2026-10-01T12:00:00", "user_id": "u1", "activity_type": "token_usage", + "token_type": "chat", "usage": {"total_tokens": 120, "model": "gpt-4o"}, "workspace_type": "group", + "workspace_context": {"group_id": "g1"}} + + +class Container: + def __init__(self, queries, rows=(), name="activity"): + self.queries = queries + self.rows = list(rows) + self.name = name + self.failure = False + + def query_items(self, **kwargs): + self.queries.append((self.name, kwargs)) + if self.failure: + raise RuntimeError("SECRET connection provider error") + query = kwargs["query"] + if "ARRAY_CONTAINS(@ids, c.id)" in query: + ids = next(item["value"] for item in kwargs["parameters"] if item["name"] == "@ids") + return [row for row in self.rows if row["id"] in ids] + if "@term" in query: + return list(self.rows) + return [dict(row) for row in self.rows] @pytest.fixture @@ -46,32 +85,36 @@ def environment(monkeypatch): and node.name in {"login_required", "control_center_required", "get_control_center_capabilities"}] exec(compile(ast.Module(body=functions, type_ignores=[]), "authentication", "exec"), auth) - class Container: - failure = False - - def query_items(self, **kwargs): - queries.append(kwargs) - if self.failure: - raise RuntimeError("SECRET connection provider error") - return [] - - container = Container() + containers = { + "activity": Container(queries), + "users": Container(queries, [{"id": "u1", "display_name": "Ada Admin", "email": "ada@example.test"}], "users"), + "groups": Container(queries, [{"id": "g1", "name": "Research"}], "groups"), + "public": Container(queries, [{"id": "p1", "name": "Library"}], "public"), + } bp = Blueprint("backend_control_center", __name__) - namespace = {**vars(activity), **auth, "bp": bp, "cosmos_activity_logs_container": container, + namespace = {**vars(activity), **vars(display), **auth, "bp": bp, + "cosmos_activity_logs_container": containers["activity"], + "cosmos_user_settings_container": containers["users"], + "cosmos_groups_container": containers["groups"], + "cosmos_public_workspaces_container": containers["public"], + "_control_center_activity_name_cache": display.ActivityNameCache(), "swagger_route": lambda **kwargs: lambda function: function, "get_auth_security": lambda: [], "Response": Response, "stream_with_context": stream_with_context, "log_event": lambda *args, **kwargs: logs.append((args, kwargs)), "logging": logging} tree = ast.parse((APP / "route_backend_control_center.py").read_text(encoding="utf-8")) + helpers = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in HELPERS] + assert {node.name for node in helpers} == HELPERS registrar = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "register_route_backend_control_center") handlers = [node for node in registrar.body if isinstance(node, ast.FunctionDef) and node.name in HANDLERS] - assert len(handlers) == 3 + assert {node.name for node in handlers} == HANDLERS for handler in handlers: decorators = [ast.unparse(item) for item in handler.decorator_list] assert decorators[1] == "swagger_route(security=get_auth_security())" + assert decorators[2] == "login_required" assert "control_center_required('activity_logs')" in decorators - exec(compile(ast.Module(body=handlers, type_ignores=[]), "activity-routes", "exec"), namespace) + exec(compile(ast.Module(body=helpers + handlers, type_ignores=[]), "activity-routes", "exec"), namespace) app.register_blueprint(bp) - yield app.test_client(), container, queries, logs, settings + yield app.test_client(), containers, queries, logs, settings def login(client, roles): @@ -84,7 +127,7 @@ def test_denied_roles_and_anonymous_never_query(environment, roles): client, _, queries, _, _ = environment if roles is not None: login(client, roles) - for path in PATHS: + for path in PATHS + LOOKUP_PATHS: response = client.get(path) assert response.status_code in (401, 302) if roles is None else response.status_code == 403 assert not queries @@ -96,7 +139,7 @@ def test_control_center_admin_and_regular_admin_settings(environment): for path in PATHS: response = client.get(path) assert response.status_code == 200 - assert len(queries) == 3 + assert [name for name, _ in queries] == ["activity", "activity", "activity"] settings["require_member_of_control_center_admin"] = False login(client, ["Admin"]) response = client.get(PATHS[0]) @@ -109,28 +152,109 @@ def test_invalid_cursor_filters_and_page_size_return_400(environment): for suffix in ("?cursor=garbage", "?page_size=201", "?page_size=abc", "?start_date=invalid"): response = client.get(PATHS[0] + suffix) assert response.status_code == 400 - assert not queries + for path in LOOKUP_PATHS: + response = client.get(path.split("?")[0] + "?q=" + "x" * 201) + assert response.status_code == 400 + assert [name for name, _ in queries if name == "activity"] == [] def test_storage_failures_are_logged_without_provider_details(environment): - client, container, _, logs, _ = environment - container.failure = True + client, containers, _, logs, _ = environment + for container in containers.values(): + container.failure = True login(client, ["ControlCenterAdmin"]) - for path in PATHS: + for path in PATHS + LOOKUP_PATHS: response = client.get(path) assert response.status_code == 500 assert "SECRET" not in response.get_data(as_text=True) - assert len(logs) == 3 + assert len(logs) == 5 + assert all(kwargs.get("level") == logging.ERROR for _, kwargs in logs) + + +def test_page_presents_names_labels_and_filter_names(environment): + client, containers, queries, _, _ = environment + containers["activity"].rows = [RECORD] + login(client, ["ControlCenterAdmin"]) + response = client.get(PATHS[0] + "?user_id=u1&workspace_type=group&workspace_id=g1") + data = response.get_json() + assert response.status_code == 200 + assert data["items"] == [RECORD] + view = data["presentation"][0] + assert view["label"] == "Token usage" and view["summary"] == "120 tokens · gpt-4o" + assert view["actor"] == {"id": "u1", "name": "Ada Admin", "email": "ada@example.test", "kind": "user", "resolved": True} + assert view["workspace"] == {"type": "group", "id": "g1", "name": "Research", "resolved": True} + assert data["filter_labels"]["person"]["name"] == "Ada Admin" + assert data["filter_labels"]["workspace"]["name"] == "Research" + assert data["search_people"] == {"matched": 0, "truncated": False} + assert [name for name, _ in queries].count("users") == 1 + + +def test_search_widens_to_matching_people_and_degrades_safely(environment): + client, containers, queries, logs, _ = environment + login(client, ["ControlCenterAdmin"]) + response = client.get(PATHS[0] + "?search=ada") + assert response.get_json()["search_people"] == {"matched": 1, "truncated": False} + activity_query = [kwargs for name, kwargs in queries if name == "activity"][-1] + assert {"name": "@search_people", "value": ["u1"]} in activity_query["parameters"] + lookups = [name for name, _ in queries].count("users") + client.get(PATHS[1] + "?search=ADA") + assert [name for name, _ in queries].count("users") == lookups, "The summary should reuse the page's people match." + containers["users"].failure = True + response = client.get(PATHS[0] + "?search=adam") + assert response.status_code == 200 and response.get_json()["search_people"]["matched"] == 0 + assert any(kwargs.get("level") == logging.WARNING for _, kwargs in logs) + + +def test_name_lookup_failure_still_returns_the_page(environment): + client, containers, _, logs, _ = environment + containers["activity"].rows = [RECORD] + containers["groups"].failure = True + login(client, ["ControlCenterAdmin"]) + response = client.get(PATHS[0]) + data = response.get_json() + assert response.status_code == 200 + assert data["presentation"][0]["actor"]["resolved"] is False + assert data["presentation"][0]["workspace"]["id"] == "g1" + assert "SECRET" not in response.get_data(as_text=True) + assert any(kwargs.get("level") == logging.WARNING for _, kwargs in logs) + + +def test_summary_labels_facets_and_lists_the_type_catalog(environment): + client, containers, _, _, _ = environment + containers["activity"].rows = [RECORD] + login(client, ["ControlCenterAdmin"]) + data = client.get(PATHS[1]).get_json() + assert data["facets"] == [{"activity_type": "token_usage", "count": 1, "label": "Token usage", "category": "tokens"}] + assert {"activity_type": "user_login", "label": "User login", "category": "sign_in", + "category_label": "Sign-in and consent"} in data["type_catalog"] + + +def test_lookups_return_people_and_workspaces(environment): + client, _, queries, _, _ = environment + login(client, ["ControlCenterAdmin"]) + people = client.get(LOOKUP_PATHS[0]).get_json() + assert people == {"people": [{"id": "u1", "display_name": "Ada Admin", "email": "ada@example.test"}]} + workspaces = client.get(LOOKUP_PATHS[1]).get_json() + assert workspaces == {"workspaces": [{"type": "group", "id": "g1", "name": "Research"}, + {"type": "public", "id": "p1", "name": "Library"}]} + queries.clear() + assert client.get("/api/v2/control-center/activity-logs/people?q=a").get_json() == {"people": []} + assert not queries -def test_csv_response_is_attachment_uncached_and_bounded(environment): - client, _, _, _, _ = environment +def test_csv_response_is_attachment_uncached_bounded_and_readable(environment): + client, containers, _, _, _ = environment + containers["activity"].rows = [RECORD] login(client, ["ControlCenterAdmin"]) response = client.get(PATHS[2] + "?date=2026-10-01") assert response.headers["X-Export-Row-Limit"] == "10000" assert response.headers["Cache-Control"] == "no-store" assert response.headers["Content-Disposition"] == 'attachment; filename="activity_logs.csv"' - assert response.get_data(as_text=True).startswith("timestamp,id,user_id,activity_type,workspace_type,raw_json") + rows = list(csv.reader(StringIO(response.get_data(as_text=True)))) + assert rows[0] == list(activity.ACTIVITY_EXPORT_HEADER) + exported = dict(zip(rows[0], rows[1])) + assert exported["user_name"] == "Ada Admin" and exported["workspace_name"] == "Research" + assert exported["activity"] == "Token usage" and exported["user_id"] == "u1" if __name__ == "__main__": diff --git a/functional_tests/test_v2_control_center_cosmos_query_compatibility.py b/functional_tests/test_v2_control_center_cosmos_query_compatibility.py index 11ca849fd..e9f9bcbc9 100644 --- a/functional_tests/test_v2_control_center_cosmos_query_compatibility.py +++ b/functional_tests/test_v2_control_center_cosmos_query_compatibility.py @@ -2,7 +2,7 @@ # test_v2_control_center_cosmos_query_compatibility.py """ Functional test for V2 Control Center Cosmos query compatibility. -Version: 0.261.292 +Version: 0.261.294 Implemented in: 0.261.292 The Dashboard, Users and Groups sections returned HTTP 500 because Cosmos DB rejected @@ -10,9 +10,15 @@ GROUP BY or COUNT over DISTINCT values, and a two-property ORDER BY needs a composite index that user_settings does not have. This test scans every SQL string in the V2 Control Center code paths and fails if one of those query shapes returns. + +Since 0.261.294 it also rejects reserved keywords used as dotted property names, such as +c.group.group_id, in every Control Center query, classic routes included. Cosmos answers +those with an HTTP 400 syntax error, which kept the Groups list, group details, the +Activity Logs group filter and every Activity Logs search failing after 0.261.292. """ import ast +import importlib.util import re import sys from pathlib import Path @@ -21,9 +27,19 @@ APP = ROOT / "application" / "single_app" ROUTE = APP / "route_backend_control_center.py" CONFIG = APP / "config.py" +CONTROL_CENTER_MODULES = ( + APP / "functions_control_center_groups.py", + APP / "functions_control_center_public_workspaces.py", + APP / "functions_control_center_activity.py", + APP / "functions_control_center_activity_display.py", +) sys.path.insert(0, str(ROOT / "functional_tests")) -from test_support.cosmos_query_guard import assert_cosmos_query_supported, cosmos_query_problems +from test_support.cosmos_query_guard import ( + assert_cosmos_query_supported, + cosmos_query_problems, + reserved_word_problems, +) from test_support.versioning import assert_app_version_at_least @@ -92,6 +108,7 @@ def _scanned_sql(): (APP / "functions_control_center_groups.py", ()), (APP / "functions_control_center_public_workspaces.py", ()), (APP / "functions_control_center_activity.py", _activity_log_composite_indexes()), + (APP / "functions_control_center_activity_display.py", ()), ): tree = ast.parse(module.read_text(encoding="utf-8")) found.extend( @@ -101,6 +118,75 @@ def _scanned_sql(): return found +def _every_control_center_sql(): + """Return (location, sql) for every query string in the Control Center modules, V1 included.""" + found = [] + for module in (ROUTE, *CONTROL_CENTER_MODULES): + tree = ast.parse(module.read_text(encoding="utf-8")) + found.extend((f"{module.name}:{line}", text) for line, text in _sql_strings(tree, _docstring_ids(tree))) + return found + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_guard_rejects_reserved_keywords_as_property_names_and_aliases(): + for query in ( + "SELECT c.id FROM c WHERE c.group.group_id = @group_id", + "SELECT TOP 20 c.id, c.group FROM c WHERE c.group_id = @group_id", + "SELECT VALUE c.id FROM c WHERE CONTAINS(c.group.group_name, @search, true)", + "SELECT c.value FROM c", + "SELECT c.id FROM c ORDER BY c.order.top", + "SELECT c.id AS value FROM c", + ): + problems = reserved_word_problems(query) + assert problems, f"Guard accepted a reserved keyword: {query}" + assert cosmos_query_problems(query), f"Combined guard accepted a reserved keyword: {query}" + for query in ( + "SELECT c.id FROM c WHERE c['group']['group_id'] = @group_id", + "SELECT c['group'] FROM c WHERE c['group'].group_name = @name", + "SELECT c.group_id, c.grouping, c.values, c.first, c.last, c.all FROM c", + "SELECT VALUE c.group_id FROM c WHERE c.type = 'document_metadata' GROUP BY c.group_id", + "SELECT c.workspace_context.group_id AS group_id FROM c ORDER BY c.timestamp DESC", + ): + assert not reserved_word_problems(query), f"Guard rejected a valid property name: {query}" + + +def test_every_control_center_query_avoids_reserved_property_names(): + scanned = _every_control_center_sql() + assert len(scanned) >= 60, f"Expected to scan the Control Center queries, found {len(scanned)}." + failures = [ + f"{location}: {'; '.join(problems)}" + for location, text in scanned + for problems in [reserved_word_problems(text)] + if problems + ] + assert not failures, "Reserved keywords used as Cosmos property names:\n" + "\n".join(failures) + + +def test_generated_activity_and_group_queries_avoid_reserved_property_names(): + """The search and group filters are generated, so the static scan cannot see them.""" + from werkzeug.datastructures import MultiDict + + activity = _load("compat_activity", APP / "functions_control_center_activity.py") + groups = _load("compat_groups", APP / "functions_control_center_groups.py") + filters = activity.parse_activity_filters(MultiDict({ + "search": "finance", "workspace_type": "group", "workspace_id": "group-1", + "group_id": "group-1", "user_id": "user-1", "status": "failed", + "activity_type": ["token_usage", "group_status_change"], + })) + where, _ = activity.activity_query_context(filters, search_user_ids=["user-2"]) + assert not reserved_word_problems(where), reserved_word_problems(where) + assert "c['group'].group_name" in where and "c['group'].group_id" in where + assert activity.cosmos_property_path("group.group_name") == "c['group'].group_name" + assert activity.cosmos_property_path("workspace_context.group_id") == "c.workspace_context.group_id" + assert not reserved_word_problems(groups.NESTED_GROUP_ID) + + def test_guard_rejects_query_shapes_the_python_sdk_cannot_run(): for query in ( "SELECT c.group_id, COUNT(1) AS total FROM c WHERE c.type = 'document_metadata' GROUP BY c.group_id", @@ -146,18 +232,22 @@ def test_scan_covers_the_previously_failing_sections(): "_dashboard_count_active_users", "_dashboard_document_upload_counts", "_dashboard_status_rows", "_dashboard_token_insights", "_dashboard_activity_insights", "_control_center_query_user_page", "_control_center_iter_users", "functions_control_center_groups.py", + "api_v2_control_center_group_detail", "functions_control_center_activity_display.py", ): assert expected in locations, f"The compatibility scan no longer reaches {expected}." def test_version_is_at_least_the_implementation_version(): - assert_app_version_at_least("0.261.292") + assert_app_version_at_least("0.261.294") TESTS = [ test_guard_rejects_query_shapes_the_python_sdk_cannot_run, test_guard_allows_supported_query_shapes, + test_guard_rejects_reserved_keywords_as_property_names_and_aliases, test_every_v2_control_center_query_is_supported_by_the_python_sdk, + test_every_control_center_query_avoids_reserved_property_names, + test_generated_activity_and_group_queries_avoid_reserved_property_names, test_scan_covers_the_previously_failing_sections, test_version_is_at_least_the_implementation_version, ] diff --git a/functional_tests/test_v2_control_center_groups.py b/functional_tests/test_v2_control_center_groups.py index 4df9502eb..ce87372aa 100644 --- a/functional_tests/test_v2_control_center_groups.py +++ b/functional_tests/test_v2_control_center_groups.py @@ -1,7 +1,7 @@ # test_v2_control_center_groups.py """ Functional tests for V2 Control Center Groups. -Version: 0.261.292 +Version: 0.261.294 Implemented in: 0.261.282 Run real filters and routes over isolated Cosmos services and the real guarded @@ -9,6 +9,8 @@ admin-only access, snapshot expiry, safe exports and approval-only actions. Since 0.261.292 the inventory fakes reject GROUP BY, because the Python Cosmos SDK cannot run it across partitions; the inventory aggregates streamed projections. +Since 0.261.294 they also reject reserved keywords used as dotted property names: +c.group.group_id is an HTTP 400 syntax error, so the nested shape is read as c['group']. """ import ast @@ -83,13 +85,15 @@ def query_items(self, query, **kwargs): return iter([{"group_id": "group-1", "tokens": 100}, {"group_id": "group-1", "tokens": 20}]) if "IIF(" in query: assert "IS_STRING(c.group_id) AND c.group_id != ''" in query - assert "IS_STRING(c.group.group_id) AND c.group.group_id != ''" in query + assert "IS_STRING(c['group']['group_id']) AND c['group']['group_id'] != ''" in query + assert "c.group.group_id" not in query return iter([ {"group_id": "group-1", "timestamp": "2026-10-06T00:00:00Z"}, {"group_id": "group-1", "timestamp": "2026-10-01T00:00:00Z"}, ]) if "TOP 20" in query: assert "c.group_id = @group_id" in query and "c.workspace_context.group_id = @group_id" in query + assert "c['group']['group_id'] = @group_id" in query and "c.group." not in query return [{"id": "event-1", "activity_type": "group_status_change", "timestamp": "2026-10-06T00:00:00Z"}] raise AssertionError(f"Unexpected activity query: {query}") @@ -257,6 +261,29 @@ def test_missing_status_and_dates_and_all_sorts_are_consistent(): assert [row["id"] for row in result] == ["b"] +def test_malformed_legacy_groups_cannot_fail_the_inventory(): + """One legacy document with unexpected shapes must not turn the list into a 500.""" + legacy = inventory_module.group_row({ + "id": "legacy", "name": 2024, "description": None, "owner": "someone@example.test", + "users": ["user-a", None, {"userId": "user-b", "displayName": 7}, {"userId": None}], + "admins": "user-b", "documentManagers": None, "metrics": [], + }, 0, 0, None) + assert legacy["name"] == "2024" and legacy["description"] == "" + assert legacy["owner"] == {"id": None, "email": "", "display_name": ""} + assert legacy["members"] == 1 + members = inventory_module.group_members({"id": "legacy", "users": "not-a-list", "owner": {"id": "owner-1"}}) + assert members == [{"id": "owner-1", "display_name": "", "email": "", "role": "Owner"}] + current = inventory_module.group_row({"id": "current", "name": "Beta", "owner": {"id": "o"}, "users": []}, 1, 2, None) + for field in inventory_module.GROUP_SORTS: + for direction in ("asc", "desc"): + filters = inventory_module.parse_group_filters({"sort": field, "direction": direction, "search": "2"}) + inventory_module.filter_group_inventory([legacy, current], filters) + rows = inventory_module.filter_group_inventory( + [legacy, current], inventory_module.parse_group_filters({"sort": "name"}), + ) + assert [row["id"] for row in rows] == ["legacy", "current"] + + def test_detail_shape_is_allowlisted_and_fresh_membership_has_roles(routes): env, client, _, _, _, _ = routes response = client.get("/api/v2/control-center/groups/group-1") diff --git a/ui_tests/test_v2_control_center_activity_logs.py b/ui_tests/test_v2_control_center_activity_logs.py index 9e8a3797d..5d822082e 100644 --- a/ui_tests/test_v2_control_center_activity_logs.py +++ b/ui_tests/test_v2_control_center_activity_logs.py @@ -1,15 +1,22 @@ # test_v2_control_center_activity_logs.py """ Browser coverage for V2 Activity Logs. -Version: 0.261.285 +Version: 0.261.294 Implemented in: 0.261.284 +Redesigned in: 0.261.294 Uses local built assets and intercepted APIs, with the shared Azure Playwright connection helper when a workspace is configured. Covers desktop and mobile. +Since 0.261.294 the page leads with Azure-portal-style filter pills, shows people and +workspaces by name, cross-filters from any person, activity or workspace in a row, keeps +saved views and display preferences on the account, and shows times in local time with a +remembered UTC toggle. """ -import sys +import json import re +import sys +from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import parse_qs, urlsplit @@ -22,13 +29,65 @@ from playwright_connection import connect_options from test_v2_control_center_users import ORIGIN, UsersFixture +pytestmark = [pytest.mark.ui, pytest.mark.browser_context_args(timezone_id="America/New_York", locale="en-US")] + +ADA = {"id": "user-1", "name": "Ada Admin", "email": "ada@example.test", "kind": "user", "resolved": True} +BO = {"id": "user-3", "name": "Bo Builder", "email": "bo@example.test", "kind": "user", "resolved": True} +RESEARCH = {"type": "group", "id": "group-1", "name": "Research", "resolved": True} +LIBRARY = {"type": "public", "id": "pub-1", "name": "Library", "resolved": True} +NO_WORKSPACE = {"type": "", "id": "", "name": "", "resolved": False} + + +def _row(record, label, category, summary, actor, workspace, detail="", facts=(), status=None): + return record, { + "activity_type": record["activity_type"], "label": label, "category": category, "summary": summary, + "detail": detail, "facts": [{"label": key, "value": value} for key, value in facts], "status": status, + "actor": actor, "workspace": workspace, + } + -RECORD = { - "id": "record-1", "timestamp": "2026-10-01T12:00:00Z", "activity_type": "user_login", - "user_id": "user-1", "workspace_type": "group", "workspace_context": {"group_id": "group-1"}, - "public_workspace_id": "workspace-1", "approval_id": "approval-1", - "description": "", -} +FIRST_PAGE = [ + _row({"id": "record-1", "timestamp": "2026-10-01T12:00:00Z", "activity_type": "token_usage", "user_id": "user-1", + "workspace_type": "group", "workspace_context": {"group_id": "group-1"}, "approval_id": "approval-1", + "description": ""}, + "Token usage", "tokens", "12,345 tokens · gpt-4o", ADA, RESEARCH, + detail="Chat · prompt 12,000 · completion 345", facts=[("Model", "gpt-4o"), ("Total tokens", "12,345")]), + _row({"id": "record-2", "timestamp": "2026-10-01T11:00:00Z", "activity_type": "group_status_change", + "changed_by": {"user_id": "admin-2", "email": "ops@example.test"}, "group": {"group_id": "group-1"}}, + "Group status changed", "groups", "Active → Locked", + {"id": "admin-2", "name": "", "email": "ops@example.test", "kind": "user", "resolved": False}, RESEARCH, + detail="Audit hold"), + _row({"id": "record-3", "timestamp": "2026-10-01T10:00:00Z", "activity_type": "document_creation", "user_id": "user-1", + "workspace_type": "public", "workspace_context": {"public_workspace_id": "pub-1"}}, + "Document created", "documents", "report.pdf", ADA, LIBRARY, detail="PDF · 2.0 KB", status="failed"), + _row({"id": "record-4", "timestamp": "2026-10-01T09:00:00Z", "activity_type": "index_auto_fix", "user_id": "system"}, + "Search index fields added", "data", "Added 2 fields to the group index", + {"id": "", "name": "", "email": "", "kind": "system", "resolved": False}, NO_WORKSPACE), +] +SECOND_PAGE = [ + _row({"id": "record-5", "timestamp": "2026-09-30T08:00:00Z", "activity_type": "user_login", "user_id": "user-3"}, + "User login", "sign_in", "Signed in", BO, NO_WORKSPACE, detail="Method: azure_ad"), +] +TYPE_CATALOG = [ + {"activity_type": "user_login", "label": "User login", "category": "sign_in", "category_label": "Sign-in and consent"}, + {"activity_type": "token_usage", "label": "Token usage", "category": "tokens", "category_label": "Token usage"}, + {"activity_type": "document_creation", "label": "Document created", "category": "documents", "category_label": "Documents"}, + {"activity_type": "group_status_change", "label": "Group status changed", "category": "groups", "category_label": "Groups"}, +] + + +def _today(): + return datetime.now(timezone.utc).date() + + +def _eventually(page, check, timeout_ms=5000, message="Timed out waiting for the expected server request."): + """Wait for state the fixture records on the server side, which the browser cannot observe.""" + waited = 0 + while not check(): + if waited >= timeout_ms: + raise AssertionError(message) + page.wait_for_timeout(50) + waited += 50 class ActivityFixture(UsersFixture): @@ -36,34 +95,68 @@ def __init__(self, page): self.fail = False self.empty = False self.allowed = True + self.settings_fail = False self.console_errors = [] + self.settings = {} + self.settings_posts = [] super().__init__(page) page.on("pageerror", lambda error: self.console_errors.append(str(error))) def _route(self, route): - parsed = urlsplit(route.request.url) + request = route.request + parsed = urlsplit(request.url) path = parsed.path - if path.startswith("/api/v2/control-center/activity-logs"): - self.requests.append((route.request.method, path, parsed.query)) + query = parse_qs(parsed.query) + if path == "/api/user/settings": + if request.method == "GET" and self.settings_fail: + route.fulfill(status=500, json={"error": "Settings are unavailable."}) + elif request.method == "GET": + route.fulfill(json={"settings": self.settings}) + else: + payload = json.loads(request.post_data or "{}")["settings"] + self.settings_posts.append(payload) + self.settings.update(payload) + route.fulfill(json={"message": "Saved."}) + elif path in ("/api/v2/control-center/activity-logs/people", "/api/v2/control-center/activity-logs/workspaces"): + self.requests.append((request.method, path, parsed.query)) + term = query.get("q", [""])[0].lower() + if path.endswith("/people"): + people = [{"id": person["id"], "display_name": person["name"], "email": person["email"]} for person in (ADA, BO)] + route.fulfill(json={"people": [item for item in people if term in item["display_name"].lower() or term == item["id"]]}) + else: + spaces = [{"type": item["type"], "id": item["id"], "name": item["name"]} for item in (RESEARCH, LIBRARY)] + route.fulfill(json={"workspaces": [item for item in spaces if term in item["name"].lower() or term == item["id"]]}) + elif path.startswith("/api/v2/control-center/activity-logs"): + self.requests.append((request.method, path, parsed.query)) if self.fail: route.fulfill(status=500, json={"error": "Unable to load activity logs. Retry."}) elif path.endswith("/summary"): route.fulfill(json={ - "facets": [{"activity_type": "user_login", "count": 5000}], - "histogram": [{"date": "2026-10-01", "count": 5000}], - "sample_size": 5000, "sample_limit": 5000, "truncated": True, "bucket_days": 1, + "facets": [{"activity_type": "token_usage", "count": 30, "label": "Token usage", "category": "tokens"}, + {"activity_type": "user_login", "count": 9, "label": "User login", "category": "sign_in"}, + {"activity_type": "document_creation", "count": 3, "label": "Document created", "category": "documents"}], + "histogram": [{"date": "2026-09-30", "count": 22}, {"date": "2026-10-01", "count": 20}, {"date": "2026-10-02", "count": 0}], + "sample_size": 42, "sample_limit": 5000, "truncated": False, "bucket_days": 1, "type_catalog": TYPE_CATALOG, }) elif path.endswith("/export.csv"): route.fulfill(content_type="text/csv", body="timestamp,id,user_id\n2026-10-01,record-1,user-1\n", headers={"Content-Disposition": 'attachment; filename="activity_logs.csv"'}) else: - second = "cursor" in parse_qs(parsed.query) - record = {**RECORD, "id": "record-2"} if second else RECORD - route.fulfill(json={"items": [] if self.empty else [record], "next_cursor": None if second else "test-cursor", - "snapshot": "2026-10-07T12:00:00"}) + rows = [] if self.empty else SECOND_PAGE if "cursor" in query else FIRST_PAGE + labels = {} + if query.get("user_id") == ["user-1"]: + labels["person"] = {key: ADA[key] for key in ("id", "name", "email", "resolved")} + if query.get("workspace_id") == ["group-1"]: + labels["workspace"] = RESEARCH + route.fulfill(json={ + "items": [record for record, _ in rows], "presentation": [view for _, view in rows], + "filter_labels": labels, "search_people": {"matched": 0, "truncated": False}, + "next_cursor": None if "cursor" in query or self.empty else "test-cursor", + "snapshot": "2026-10-07T12:00:00", + }) elif path == "/api/v2/bootstrap" and not self.allowed: route.fulfill(json={ - "version": "0.261.284", "user": {"id": "reader", "display_name": "Reader", "is_admin": False, "roles": ["ControlCenterDashboardReader"]}, + "version": "0.261.294", "user": {"id": "reader", "display_name": "Reader", "is_admin": False, "roles": ["ControlCenterDashboardReader"]}, "branding": {"app_title": "SimpleChat", "show_logo": False}, "features": {}, "control_center": {"can_view_dashboard": True, "can_manage_users": False, "can_manage_groups": False, "can_manage_workspaces": False, "can_view_activity_logs": False, "can_run_maintenance": False}, @@ -74,10 +167,13 @@ def _route(self, route): else: super()._route(route) - def open(self, query="?date=2026-10-01&activity_type=user_login&workspace_type=group&workspace_id=group-1", mobile=False): + def open(self, query="", mobile=False): self.page.set_viewport_size({"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900}) self.page.goto(f"{ORIGIN}/v2/control-center/activity-logs{query}", wait_until="networkidle") + def page_queries(self): + return [parse_qs(query) for _, path, query in self.requests if path.endswith("/activity-logs")] + def assert_clean(self): super().assert_clean() assert not self.console_errors, self.console_errors @@ -90,81 +186,356 @@ def activity_ui(page): fixture.assert_clean() -pytestmark = pytest.mark.ui +def test_first_viewport_shows_named_readable_rows(activity_ui): + activity_ui.open() + page = activity_ui.page + expect(page.get_by_role("heading", name="Activity Logs", exact=True)).to_be_visible() + expect(page.get_by_role("button", name="Date: Last 30 days")).to_be_visible() + person = page.get_by_role("button", name="Show only activity by Ada Admin").first + expect(person).to_be_visible() + box = person.bounding_box() + assert box and box["y"] + box["height"] < 900, "The first log row should be visible without scrolling." + expect(page.get_by_role("button", name="Show only activity in Research (Group)").first).to_be_visible() + table = page.get_by_role("table") + expect(table.get_by_text("12,345 tokens · gpt-4o")).to_be_visible() + expect(table.get_by_text("Chat · prompt 12,000 · completion 345")).to_be_visible() + expect(page.get_by_role("button", name="Show only activity by ops@example.test")).to_be_visible() + expect(table.get_by_text("Failed", exact=True)).to_be_visible() + expect(page.get_by_role("cell", name="System", exact=True)).to_be_visible() + expect(page.get_by_role("cell", name=re.compile("user-1"))).to_have_count(0) + expect(page.get_by_role("region", name="Activity trend").get_by_text("42 records", exact=False)).to_be_visible() + latest = activity_ui.page_queries()[-1] + assert latest["end_date"] == [_today().isoformat()] + assert latest["start_date"] == [(_today() - timedelta(days=29)).isoformat()] + + +def test_cross_filters_from_rows_show_names_in_pills(activity_ui): + activity_ui.open() + page = activity_ui.page + page.get_by_role("button", name="Show only activity by Ada Admin").first.click() + expect(page).to_have_url(re.compile(r"user_id=user-1")) + expect(page.get_by_role("button", name="Person: Ada Admin")).to_be_visible() + # The rows reload, so focus moves to the filter that was just set. + expect(page.get_by_role("button", name="Person: Ada Admin")).to_be_focused() + page.get_by_role("button", name="Show only activity in Research (Group)").first.click() + expect(page).to_have_url(re.compile(r"workspace_type=group&workspace_id=group-1")) + expect(page.get_by_role("button", name="Group: Research")).to_be_focused() + page.get_by_role("button", name="Show only Token usage activity").click() + expect(page).to_have_url(re.compile(r"activity_type=token_usage")) + expect(page.get_by_role("button", name="Activity: Token usage")).to_be_focused() + latest = activity_ui.page_queries()[-1] + assert latest["user_id"] == ["user-1"] and latest["workspace_id"] == ["group-1"] + assert latest["activity_type"] == ["token_usage"] and "cursor" not in latest + page.get_by_role("button", name="Clear person filter").click() + expect(page).not_to_have_url(re.compile(r"user_id=")) + expect(page.get_by_role("button", name="Person: Anyone")).to_be_focused() + page.get_by_role("button", name="Reset filters").first.click() + expect(page).to_have_url(f"{ORIGIN}/v2/control-center/activity-logs") + expect(page.get_by_label("Search activity")).to_be_focused() -@pytest.mark.parametrize("mobile", [False, True]) -def test_filters_paging_details_and_safe_links(activity_ui, mobile): - activity_ui.open(mobile=mobile) +def test_search_keeps_what_is_typed_while_the_url_updates(activity_ui): + activity_ui.open() page = activity_ui.page - expect(page.get_by_role("heading", name="Activity Logs", exact=True)).to_be_visible() - expect(page.get_by_label("Start date (UTC)")).to_have_value("2026-10-01") - expect(page.get_by_text("Sampled: newest 5,000 matching records.", exact=False)).to_be_visible() - page.get_by_role("button", name="Inspect activity record-1").click() + search = page.get_by_label("Search activity") + search.press_sequentially("Ada ") + expect(page).to_have_url(re.compile(r"search=Ada(&|$)")) + # The URL's echo of "Ada" must not trim the space the administrator is still typing after. + search.press_sequentially("Admin") + expect(page).to_have_url(re.compile(r"search=Ada\+Admin")) + expect(search).to_have_value("Ada Admin") + assert activity_ui.page_queries()[-1]["search"] == ["Ada Admin"] + page.get_by_role("button", name="Reset filters").first.click() + expect(search).to_have_value("") + + +def test_pickers_offer_filtering_by_the_id_of_something_removed(activity_ui): + activity_ui.open() + page = activity_ui.page + page.get_by_role("button", name="Person: Anyone").click() + picker = page.get_by_role("combobox", name="Find a person") + picker.fill("user-9") + expect(page.get_by_text("No SimpleChat user matches.")).to_be_visible() + expect(page.get_by_role("option", name=re.compile("Filter by user ID"))).to_be_visible() + picker.press("Enter") + expect(page).to_have_url(re.compile(r"user_id=user-9")) + expect(page.get_by_role("button", name="Person: user-9")).to_be_focused() + page.get_by_role("button", name="Workspace: Any").click() + page.get_by_role("combobox", name="Find a group or public workspace").fill("pub-77") + expect(page.get_by_role("option", name=re.compile("Filter by group ID"))).to_be_visible() + page.get_by_role("option", name=re.compile("Filter by public workspace ID")).click() + expect(page).to_have_url(re.compile(r"workspace_type=public&workspace_id=pub-77")) + expect(page.get_by_role("button", name="Public workspace: pub-77")).to_be_visible() + latest = activity_ui.page_queries()[-1] + assert latest["user_id"] == ["user-9"] and latest["workspace_id"] == ["pub-77"] + page.get_by_role("button", name="Person: user-9").click() + page.get_by_role("combobox", name="Find a person").fill("jane doe") + expect(page.get_by_text("No SimpleChat user matches.")).to_be_visible() + expect(page.get_by_role("option")).to_have_count(0) + + +def test_pills_edit_filters_with_keyboard_pickers(activity_ui): + activity_ui.open() + page = activity_ui.page + page.get_by_role("button", name="Person: Anyone").click() + person = page.get_by_role("combobox", name="Find a person") + expect(person).to_be_focused() + person.fill("bo") + expect(page.get_by_role("option", name=re.compile("Bo Builder"))).to_be_visible() + person.press("Enter") + expect(page).to_have_url(re.compile(r"user_id=user-3")) + expect(page.get_by_role("button", name="Person: Bo Builder")).to_be_focused() + + page.get_by_role("button", name="Workspace: Any").click() + page.get_by_role("group", name="Workspace type").get_by_role("button", name="Groups").click() + expect(page).to_have_url(re.compile(r"workspace_type=group")) + page.get_by_role("combobox", name="Find a group").fill("res") + page.get_by_role("option", name=re.compile("Research")).click() + expect(page).to_have_url(re.compile(r"workspace_id=group-1")) + expect(page.get_by_role("button", name="Group: Research")).to_be_visible() + + page.get_by_role("button", name="Date: Last 30 days").click() + page.get_by_role("button", name="Last 7 days").click() + expect(page).to_have_url(re.compile(r"range=7")) + assert activity_ui.page_queries()[-1]["start_date"] == [(_today() - timedelta(days=6)).isoformat()] + page.get_by_role("button", name="Date: Last 7 days").click() + page.get_by_role("button", name="Custom range").click() + page.get_by_label("Start date (UTC)").fill("2026-09-10") + page.get_by_label("End date (UTC)").fill("2026-09-01") + page.get_by_role("button", name="Apply range").click() + expect(page.get_by_text("The end date must be on or after the start date.")).to_be_visible() + page.get_by_label("End date (UTC)").fill("2026-09-20") + page.get_by_role("button", name="Apply range").click() + expect(page).to_have_url(re.compile(r"start_date=2026-09-10&end_date=2026-09-20")) + expect(page.get_by_role("button", name="Date: Sep 10 – Sep 20, 2026 (UTC)")).to_be_visible() + + page.get_by_role("button", name="Activity: All").click() + page.get_by_label("Find an activity type").fill("login") + page.get_by_role("checkbox", name=re.compile("User login")).check() + expect(page).to_have_url(re.compile(r"activity_type=user_login")) + page.keyboard.press("Escape") + expect(page.get_by_role("button", name="Activity: User login")).to_be_focused() + + page.get_by_role("button", name="Add filter").click() + page.get_by_role("button", name="Model").click() + page.get_by_label("Model or deployment name").fill("gpt-4o") + page.get_by_role("button", name="Apply", exact=True).click() + expect(page).to_have_url(re.compile(r"model=gpt-4o")) + # The added pill stays put while the URL catches up, and keeps keyboard focus. + expect(page.get_by_role("button", name="Model: gpt-4o")).to_be_focused() + latest = activity_ui.page_queries()[-1] + assert latest["user_id"] == ["user-3"] and latest["model"] == ["gpt-4o"] + + page.get_by_role("button", name="Add filter").click() + page.get_by_role("button", name="Token type").click() + expect(page.get_by_role("dialog", name="Filter by token type")).to_be_visible() + page.keyboard.press("Escape") + expect(page.get_by_role("button", name=re.compile(r"^Token type:"))).to_have_count(0) + expect(page.get_by_role("button", name="Add filter")).to_be_focused() + expect(page).not_to_have_url(re.compile(r"token_type=")) + + +def test_deep_links_render_as_named_pills(activity_ui): + activity_ui.open("?date=2026-10-01&activity_type=user_login&workspace_type=group&workspace_id=group-1&group_id=group-1&user_id=user-1") + page = activity_ui.page + expect(page.get_by_role("button", name="Date: Oct 1, 2026 (UTC)")).to_be_visible() + expect(page.get_by_role("button", name="Activity: User login")).to_be_visible() + expect(page.get_by_role("button", name="Group: Research")).to_be_visible() + expect(page.get_by_role("button", name="Person: Ada Admin")).to_be_visible() + latest = activity_ui.page_queries()[-1] + assert latest["start_date"] == ["2026-10-01"] and latest["end_date"] == ["2026-10-01"] + assert latest["workspace_type"] == ["group"] and latest["workspace_id"] == ["group-1"] + + +def test_trend_drill_through_keyboard_and_top_activity(activity_ui): + activity_ui.open() + page = activity_ui.page + first_bar = page.get_by_role("button", name=re.compile(r"^Sep 30 \(UTC\): 22 records")) + first_bar.focus() + page.keyboard.press("ArrowRight") + expect(page.get_by_role("button", name=re.compile(r"^Oct 1 \(UTC\): 20 records"))).to_be_focused() + page.keyboard.press("Enter") + expect(page).to_have_url(re.compile(r"start_date=2026-10-01&end_date=2026-10-01")) + expect(page.get_by_role("button", name="Date: Oct 1, 2026 (UTC)")).to_be_focused() + page.get_by_role("button", name=re.compile(r"^Token usage: 30 records")).click() + expect(page).to_have_url(re.compile(r"activity_type=token_usage")) + page.get_by_role("button", name="Hide trend").click() + expect(page.get_by_role("toolbar", name=re.compile("Activity by UTC date"))).to_have_count(0) + _eventually(page, lambda: activity_ui.settings.get("v2ActivityLogPrefs", {}).get("showTrend") is False) + page.reload(wait_until="networkidle") + expect(page.get_by_role("button", name="Show trend")).to_be_visible() + + +def test_detail_drawer_reads_as_who_where_what(activity_ui): + activity_ui.open() + page = activity_ui.page + opener = page.get_by_role("button", name=re.compile(r"^Open details: Token usage, Ada Admin")) + opener.click() drawer = page.get_by_role("dialog", name="Activity details") expect(drawer).to_be_visible() + for heading in ("Who", "Where", "Details", "Record"): + expect(drawer.get_by_role("heading", name=heading, exact=True)).to_be_visible() + expect(drawer.get_by_text("12,345 tokens · gpt-4o")).to_be_visible() + expect(drawer.get_by_role("link", name="Open in Users")).to_have_attribute("href", "/v2/control-center/users?user_id=user-1") + expect(drawer.get_by_role("link", name="Open group")).to_have_attribute("href", "/v2/control-center/groups?id=group-1") + expect(drawer.get_by_role("link", name="View approval")).to_have_attribute("href", "/v2/approvals/all/approval-1?group_id=group-1") + expect(drawer.get_by_text("2026-10-01 12:00:00 UTC")).to_be_visible() + drawer.get_by_text("Raw JSON", exact=True).click() expect(drawer.locator("pre")).to_contain_text("") expect(page.locator("img[src=x]")).to_have_count(0) - expect(drawer.get_by_role("link", name="View user")).to_have_attribute("href", "/v2/control-center/users?user_id=user-1") - expect(drawer.get_by_role("link", name="View group")).to_have_attribute("href", "/v2/control-center/groups?id=group-1") - expect(drawer.get_by_role("link", name="View workspace")).to_have_attribute("href", "/v2/control-center/public-workspaces?id=workspace-1") - expect(drawer.get_by_role("link", name="View approval")).to_have_attribute("href", "/v2/approvals/all/approval-1?group_id=group-1") + drawer.get_by_role("button", name="Next record").click() + expect(drawer.get_by_text("Active → Locked")).to_be_visible() + expect(drawer.get_by_text("Record 2 of 4 on this page")).to_be_visible() page.keyboard.press("Escape") expect(drawer).not_to_be_visible() - expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_focused() - page.get_by_role("button", name="Next", exact=True).click() - expect(page.get_by_role("button", name="Inspect activity record-2")).to_be_visible() - page.get_by_role("button", name="Previous", exact=True).click() - expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_visible() - page.get_by_label("Search activity").fill("file") - page.get_by_role("button", name="Apply filters").click() - expect(page).to_have_url(re.compile("search=file")) - expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_visible() - latest = [parse_qs(query) for _, path, query in activity_ui.requests if path.endswith("activity-logs")][-1] - assert latest["search"] == ["file"] and "cursor" not in latest - page.get_by_label("Density").select_option("compact") - assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + expect(opener).to_be_focused() + opener.click() + page.get_by_role("dialog", name="Activity details").get_by_role("button", name="Show only this person's activity").click() + expect(page).to_have_url(re.compile(r"user_id=user-1")) + expect(page.get_by_role("dialog", name="Activity details")).not_to_be_visible() + expect(page.get_by_role("button", name="Person: Ada Admin")).to_be_focused() -def test_saved_views_filters_export_and_histogram(activity_ui): +def test_times_default_to_local_and_the_utc_choice_is_remembered(activity_ui): activity_ui.open() page = activity_ui.page - page.get_by_label("View name", exact=True).fill("Group logins") - page.get_by_role("button", name="Save current filters").click() - page.get_by_role("button", name="Clear filters").click() - page.get_by_label("Saved views", exact=True).select_option("Group logins") - expect(page.get_by_label("Workspace ID", exact=True)).to_have_value("group-1") + expect(page.get_by_role("table").get_by_text(re.compile(r"^Oct 1, 8:00:00\sAM$"))).to_be_visible() + page.get_by_role("group", name="Show times in").get_by_role("button", name="UTC").click() + expect(page.get_by_role("table").get_by_text("2026-10-01 12:00:00 UTC", exact=True)).to_be_visible() + page.get_by_role("group", name="Row density").get_by_role("button", name="Compact").click() + _eventually(page, lambda: activity_ui.settings.get("v2ActivityLogPrefs") == { + "timeZone": "utc", "density": "compact", "showTrend": True}) page.reload(wait_until="networkidle") - expect(page.get_by_label("Saved views").locator("option")).to_have_count(2) - page.get_by_role("button", name="token usage", exact=True).click() - expect(page).to_have_url(re.compile("activity_type=token_usage")) - with page.expect_download() as download: + expect(page.get_by_role("table").get_by_text("2026-10-01 12:00:00 UTC", exact=True)).to_be_visible() + expect(page.get_by_role("group", name="Row density").get_by_role("button", name="Compact")).to_have_attribute("aria-pressed", "true") + + +def test_saved_views_move_to_the_account_and_quick_views_apply(activity_ui): + legacy = json.dumps([{"name": "Old view", "query": "start_date=2026-09-01&end_date=2026-09-30&activity_type=user_login"}]) + activity_ui.page.add_init_script( + "if (!sessionStorage.getItem('seeded')) {" + f" localStorage.setItem('simplechat.activity-views.admin-1', {json.dumps(legacy)});" + " sessionStorage.setItem('seeded', '1'); }" + ) + activity_ui.open() + page = activity_ui.page + page.wait_for_function("() => localStorage.getItem('simplechat.activity-views.admin-1') === null") + assert [view["name"] for view in activity_ui.settings["v2ActivityLogSavedViews"]] == ["Old view"] + + page.get_by_role("button", name="Views").click() + menu = page.get_by_role("dialog", name="Activity views") + expect(menu.get_by_text("Saved to your account", exact=False)).to_be_visible() + menu.get_by_role("button", name="Old view", exact=True).click() + expect(page).to_have_url(re.compile(r"start_date=2026-09-01&end_date=2026-09-30&activity_type=user_login")) + + page.get_by_role("button", name="Views").click() + menu = page.get_by_role("dialog", name="Activity views") + menu.get_by_role("button", name="Recent sign-ins").click() + expect(page).to_have_url(re.compile(r"range=7&activity_type=user_login")) + + page.get_by_role("button", name="Views").click() + menu = page.get_by_role("dialog", name="Activity views") + menu.get_by_label("Save the current filters as").fill("Weekly sign-ins") + menu.get_by_role("button", name="Save view").click() + + def saved(): + return {view["name"]: view["query"] for view in activity_ui.settings.get("v2ActivityLogSavedViews", [])} + + _eventually(page, lambda: saved().get("Weekly sign-ins") == "range=7&activity_type=user_login") + menu.get_by_role("button", name="Rename saved view Weekly sign-ins").click() + menu.get_by_role("textbox", name="New name for Weekly sign-ins").fill("Sign-ins this week") + menu.get_by_role("button", name="Save the new name for Weekly sign-ins").click() + menu.get_by_role("button", name="Delete saved view Old view").click() + _eventually(page, lambda: list(saved()) == ["Sign-ins this week"]) + page.reload(wait_until="networkidle") + assert page.evaluate("localStorage.getItem('simplechat.activity-views.admin-1')") is None + + +def test_saved_views_are_locked_when_settings_cannot_load(activity_ui): + """A save built on views that never loaded would replace the account's real views.""" + activity_ui.settings_fail = True + activity_ui.page.add_init_script( + "localStorage.setItem('simplechat.activity-views.admin-1'," + " JSON.stringify([{name: 'Local view', query: 'range=7'}]));" + ) + activity_ui.open() + page = activity_ui.page + page.get_by_role("button", name="Views").click() + menu = page.get_by_role("dialog", name="Activity views") + expect(menu.get_by_text("Your saved views could not be loaded.", exact=False)).to_be_visible() + expect(menu.get_by_label("Save the current filters as")).to_have_count(0) + menu.get_by_role("button", name="Recent sign-ins").click() + expect(page).to_have_url(re.compile(r"range=7&activity_type=user_login")) + assert not activity_ui.settings_posts + assert page.evaluate("localStorage.getItem('simplechat.activity-views.admin-1')") is not None + + +def test_relative_ranges_follow_the_clock_on_refresh_and_export(activity_ui): + page = activity_ui.page + page.clock.set_fixed_time(datetime(2026, 10, 7, 23, 59, 30, tzinfo=timezone.utc)) + activity_ui.open("?range=today") + expect(page.get_by_role("button", name="Date: Today")).to_be_visible() + first = activity_ui.page_queries()[-1] + assert first["start_date"] == first["end_date"] == ["2026-10-07"] + + page.clock.set_fixed_time(datetime(2026, 10, 8, 0, 0, 30, tzinfo=timezone.utc)) + page.get_by_role("button", name="Refresh", exact=True).click() + _eventually(page, lambda: activity_ui.page_queries()[-1]["start_date"] == ["2026-10-08"], + message="Refresh kept querying the previous UTC day.") + assert activity_ui.page_queries()[-1]["end_date"] == ["2026-10-08"] + + page.clock.set_fixed_time(datetime(2026, 10, 9, 0, 0, 30, tzinfo=timezone.utc)) + with page.expect_download(): page.get_by_role("button", name="Export CSV", exact=True).click() - assert download.value.suggested_filename == "activity_logs.csv" export_query = [parse_qs(query) for _, path, query in activity_ui.requests if path.endswith("export.csv")][-1] - assert export_query["activity_type"] == ["user_login", "token_usage"] - page.get_by_text("Histogram data and date drill-through", exact=True).click() - page.get_by_role("button", name="2026-10-01", exact=True).click() - expect(page.get_by_label("End date (UTC)")).to_have_value("2026-10-01") - page.get_by_role("button", name="Recent logins", exact=True).click() - expect(page).to_have_url(re.compile("activity_type=user_login")) - expect(page.get_by_label("Workspace ID", exact=True)).to_have_value("") + assert export_query["start_date"] == export_query["end_date"] == ["2026-10-09"] + _eventually(page, lambda: activity_ui.page_queries()[-1]["start_date"] == ["2026-10-09"], + message="The log did not move to the new UTC day after the export.") -def test_empty_error_retry_and_permission_gating(activity_ui): - activity_ui.empty = True +def test_paging_export_empty_error_and_permission_gating(activity_ui): activity_ui.open() page = activity_ui.page - expect(page.get_by_text("No activity matches these filters.", exact=False)).to_be_visible() + page.get_by_role("button", name="Next", exact=True).click() + expect(page.get_by_role("button", name="Show only activity by Bo Builder")).to_be_visible() + assert activity_ui.page_queries()[-1]["cursor"] == ["test-cursor"] + page.get_by_role("button", name="Previous", exact=True).click() + expect(page.get_by_role("button", name="Show only activity by Ada Admin").first).to_be_visible() + with page.expect_download() as download: + page.get_by_role("button", name="Export CSV", exact=True).click() + assert download.value.suggested_filename == "activity_logs.csv" + export_query = [parse_qs(query) for _, path, query in activity_ui.requests if path.endswith("export.csv")][-1] + assert export_query["start_date"] == [(_today() - timedelta(days=29)).isoformat()] + + activity_ui.empty = True + page.get_by_role("button", name="Refresh", exact=True).click() + expect(page.get_by_text("No activity matches these filters.")).to_be_visible() + page.get_by_role("button", name="Widen to the last 90 days").click() + expect(page).to_have_url(re.compile(r"range=90")) activity_ui.empty = False activity_ui.fail = True page.get_by_role("button", name="Refresh", exact=True).click() expect(page.get_by_role("button", name="Retry", exact=True)).to_be_visible() activity_ui.fail = False page.get_by_role("button", name="Retry", exact=True).click() - expect(page.get_by_role("button", name="Inspect activity record-1")).to_be_visible() + expect(page.get_by_role("button", name="Show only activity by Ada Admin").first).to_be_visible() + activity_ui.allowed = False activity_ui.requests.clear() page.reload(wait_until="networkidle") expect(page.get_by_role("heading", name="Access unavailable")).to_be_visible() assert not activity_ui.requests + + +def test_mobile_layout_stacks_rows_without_overflow(activity_ui): + activity_ui.open(mobile=True) + page = activity_ui.page + expect(page.get_by_role("button", name="Date: Last 30 days")).to_be_visible() + expect(page.get_by_role("list", name="Activity records, newest first")).to_be_visible() + expect(page.get_by_role("table")).to_have_count(0) + page.get_by_role("button", name=re.compile(r"^Open details: Token usage, Ada Admin")).click() + expect(page.get_by_role("dialog", name="Activity details")).to_be_visible() + page.keyboard.press("Escape") + page.get_by_role("button", name="Show only activity by Ada Admin").first.click() + expect(page).to_have_url(re.compile(r"user_id=user-1")) + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") From 284ebe68185171b7c83cb20e9d28c7d0d2d66874 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 21:40:56 -0400 Subject: [PATCH 2/2] Bump version to 0.261.296 for the Control Center activity logs UX The V2 branch moved to 0.261.295 (workflow hand-off card), so this change and its docs and tests move from 0.261.294 to 0.261.296. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- docs/explanation/features/V2_CONTROL_CENTER.md | 14 +++++++------- ...ONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md | 2 +- ...V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md | 4 ++-- docs/guides/v2-control-center.md | 4 ++-- .../test_support/cosmos_query_guard.py | 2 +- .../test_v2_control_center_activity_display.py | 6 +++--- ...test_v2_control_center_activity_logs_queries.py | 4 ++-- .../test_v2_control_center_activity_logs_routes.py | 4 ++-- ...v2_control_center_cosmos_query_compatibility.py | 6 +++--- functional_tests/test_v2_control_center_groups.py | 4 ++-- ui_tests/test_v2_control_center_activity_logs.py | 8 ++++---- 12 files changed, 30 insertions(+), 30 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 24a90b32c..031f58018 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.295" +VERSION = "0.261.296" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index 8ae6fec72..8bb29bca7 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -8,7 +8,7 @@ The V2 Control Center is a permission-aware administration pane for managing Sim **Groups implemented in version:** 0.261.282 **Activity Logs implemented in version:** 0.261.284 **Public Workspaces implemented in version:** 0.261.283 -**Current version:** 0.261.294 (Activity Logs redesign with names and filter pills; Groups, group details, the Activity Logs group filter and Activity Logs search no longer fail on the reserved `group` keyword) +**Current version:** 0.261.296 (Activity Logs redesign with names and filter pills; Groups, group details, the Activity Logs group filter and Activity Logs search no longer fail on the reserved `group` keyword) **Dependencies:** React 18, TypeScript, Vite, Flask session authentication, and the existing Control Center APIs. @@ -80,7 +80,7 @@ The Groups section helps administrators find shared workspaces that need attenti | `page`, `per_page` | Server paging, default 25 and maximum 250 rows | | `force_refresh=1` | Rebuild the server inventory instead of using its 90-second cache | -`functions_control_center_groups.py` builds an inventory with four batched Cosmos queries. One projects the groups. The other three stream narrow projections that the application aggregates into document counts, all-time token totals and latest activity timestamps: document metadata group IDs, group token records, and a coalesced group ID with timestamp. The Python Cosmos SDK cannot run cross-partition `GROUP BY`. Activity includes the top-level `group_id`, nested `group.group_id`, and `workspace_context.group_id` writer shapes. `GROUP` is a reserved word in Cosmos SQL, so the nested shape is read as `c['group']['group_id']`; the dotted `c.group.group_id` is a syntax error that rejected the whole inventory query until 0.261.294. Filtering, sorting and paging occur on the server after aggregation; neither the browser nor per-row enrichment performs filtering or counting. This deliberately avoids N+1 queries and Cosmos ordering on undefined/computed fields. Tied sort values use stable ID ordering and missing activity sorts last in either direction. Failed aggregates fail the request rather than silently reporting zero. A legacy group document with a non-object owner, non-object member entries or a non-text name is listed with those values treated as missing instead of failing the whole list. List and detail failures log the Cosmos status code with the error type. +`functions_control_center_groups.py` builds an inventory with four batched Cosmos queries. One projects the groups. The other three stream narrow projections that the application aggregates into document counts, all-time token totals and latest activity timestamps: document metadata group IDs, group token records, and a coalesced group ID with timestamp. The Python Cosmos SDK cannot run cross-partition `GROUP BY`. Activity includes the top-level `group_id`, nested `group.group_id`, and `workspace_context.group_id` writer shapes. `GROUP` is a reserved word in Cosmos SQL, so the nested shape is read as `c['group']['group_id']`; the dotted `c.group.group_id` is a syntax error that rejected the whole inventory query until 0.261.296. Filtering, sorting and paging occur on the server after aggregation; neither the browser nor per-row enrichment performs filtering or counting. This deliberately avoids N+1 queries and Cosmos ordering on undefined/computed fields. Tied sort values use stable ID ordering and missing activity sorts last in either direction. Failed aggregates fail the request rather than silently reporting zero. A legacy group document with a non-object owner, non-object member entries or a non-text name is listed with those values treated as missing instead of failing the whole list. List and detail failures log the Cosmos status code with the error type. The response includes `groups`, `pagination`, and `metrics_freshness` with the snapshot's `calculated_at`, source and TTL. List/CSV totals can lag external writes by up to 90 seconds; **Refresh groups** bypasses the cache. Rebuild cost scales with the number of group documents and all-time group token and activity records, while memory holds only per-group totals. This is not continuation-token pagination. Legacy storage-size estimates retain their own older refresh timestamp and are not represented as current counts. @@ -106,7 +106,7 @@ Delete group, delete all documents, take ownership and transfer ownership reuse ## Activity Logs -Implemented in version: **0.261.284**, tracked by `VERSION` in `application/single_app/config.py`. Redesigned in **0.261.294**. +Implemented in version: **0.261.284**, tracked by `VERSION` in `application/single_app/config.py`. Redesigned in **0.261.296**. Activity Logs is an investigation surface for administrators with `can_view_activity_logs`. It links dashboard trends, a user's recent activity, and workspace timelines to the same filtered evidence, and answers who did what, where and when in human terms: people and workspaces appear by name, and any person, activity type or workspace in a row filters the log to it. All five APIs use the existing login-protected Control Center Blueprint, `@swagger_route(security=get_auth_security())`, and `control_center_required('activity_logs')`; dashboard-only readers cannot query, look up names or export activity. @@ -114,9 +114,9 @@ Activity Logs is an investigation surface for administrators with `can_view_acti `GET /api/v2/control-center/activity-logs` accepts inclusive UTC `start_date`/`end_date`, or the dashboard's single-day `date`. The default is the latest 30 UTC dates; ranges are limited to 366 days. Filters include repeated or comma-separated `activity_type` values (OR within types, AND with other filters), `user_id`, `workspace_type`, `workspace_id`, `group_id`, `public_workspace_id`, `search`, `token_type`, `model`, and recorded `status`. Search is a case-insensitive substring across stored IDs, actor emails, names, descriptions, file names, conversation titles and model names, not a full-text index. It is parameterized, limited to 200 characters, and does not trigger Graph enrichment. -Since 0.261.294 search also finds what a person did when the term matches their name or email. When the term has at least two characters, the route looks up to 25 SimpleChat users whose `user_settings` display name or email contains it and adds them to the search as actors. The page, summary and export reuse a term's matches from the same five-minute cache as the names. More than 25 matches sets `search_people.truncated`, and the page suggests the Person filter. The matched IDs widen the search only; they are not part of the cursor's filter scope, so paging continues if a new user matches between pages. A failed people lookup is logged and the search falls back to the stored fields. +Since 0.261.296 search also finds what a person did when the term matches their name or email. When the term has at least two characters, the route looks up to 25 SimpleChat users whose `user_settings` display name or email contains it and adds them to the search as actors. The page, summary and export reuse a term's matches from the same five-minute cache as the names. More than 25 matches sets `search_people.truncated`, and the page suggests the Person filter. The matched IDs widen the search only; they are not part of the cursor's filter scope, so paging continues if a new user matches between pages. A failed people lookup is logged and the search falls back to the stored fields. -The query recognizes top-level and nested group/public-workspace identifiers and the historical `public_workspace` workspace-type spelling. `GROUP` is a reserved word in Cosmos SQL, so the nested `group` object is read as `c['group']`; before 0.261.294 the dotted form made every search and every group filter fail with HTTP 400. `workspace_id` requires a workspace type; for personal workspaces it matches the user's ID. A specific group or public workspace matches every record that references it, whatever workspace type the writer stored, so membership removals and role changes, which record a group but no workspace type, are included. Group references are read from `workspace_context.group_id`, `group_id`, `group.group_id` and `workspace_context.group_workspace_id` (user agreement acceptances). Public workspace references are read from `workspace_context.public_workspace_id`, `public_workspace_id`, `public_workspace.public_workspace_id` (membership removals and access requests), `public_workspace.workspace_id` (status changes) and the bare `workspace_id` that public workspace ownership approvals record. A workspace type on its own (`group` or `public`) matches records of that type or that reference such a workspace. The person filter matches every field where writers record who acted: `user_id`, `admin_user_id`, `requester_id`, `added_by_user_id`, `changed_by_user_id`, `changed_by.user_id`, `removed_by.user_id`, `admin.user_id` and `actor.user_id`. `status=failed` matches recorded failure/error text; it does not infer failures from unrecorded events. +The query recognizes top-level and nested group/public-workspace identifiers and the historical `public_workspace` workspace-type spelling. `GROUP` is a reserved word in Cosmos SQL, so the nested `group` object is read as `c['group']`; before 0.261.296 the dotted form made every search and every group filter fail with HTTP 400. `workspace_id` requires a workspace type; for personal workspaces it matches the user's ID. A specific group or public workspace matches every record that references it, whatever workspace type the writer stored, so membership removals and role changes, which record a group but no workspace type, are included. Group references are read from `workspace_context.group_id`, `group_id`, `group.group_id` and `workspace_context.group_workspace_id` (user agreement acceptances). Public workspace references are read from `workspace_context.public_workspace_id`, `public_workspace_id`, `public_workspace.public_workspace_id` (membership removals and access requests), `public_workspace.workspace_id` (status changes) and the bare `workspace_id` that public workspace ownership approvals record. A workspace type on its own (`group` or `public`) matches records of that type or that reference such a workspace. The person filter matches every field where writers record who acted: `user_id`, `admin_user_id`, `requester_id`, `added_by_user_id`, `changed_by_user_id`, `changed_by.user_id`, `removed_by.user_id`, `admin.user_id` and `actor.user_id`. `status=failed` matches recorded failure/error text; it does not infer failures from unrecorded events. Responses contain `items`, `presentation`, `filter_labels`, `search_people`, `next_cursor`, `page_size`, and `snapshot`. Page size defaults to 50 and is bounded at 200. Paging uses a descending keyset of the **stored timestamp string, ID, and user partition**, not Cosmos continuation tokens, `OFFSET`, or a total-count scan. The partition tie-breaker is required because Cosmos IDs are unique only within a partition. Cursors retain the original timestamp spelling, distinguish missing/null partition values, carry a time cutoff, and reject reuse with different filters. Refresh starts a new sequence. The cutoff excludes newer timestamped events, but is not a Cosmos transactional snapshot: deletion, edits, or late/backdated writes can change an ongoing investigation. Records without string timestamps or IDs, or with malformed non-string/non-null user partitions, cannot participate in this ordered feed; legacy browsing remains available for those records. @@ -216,8 +216,8 @@ Open **Account → Control Center**, then select a section in its internal rail. ## Testing and limitations -Functional checks cover dashboard status normalization, period deltas, cache expiry and refresh, dashboard-reader access, capability parity, bootstrap exposure, and route wiring. Route-level tests run the real Dashboard and Users handlers, and the Groups inventory, against fake containers that reject query shapes the Python Cosmos SDK cannot run. `functional_tests/test_v2_control_center_cosmos_query_compatibility.py` scans every V2 Control Center SQL string for cross-partition `GROUP BY`, `COUNT` over `DISTINCT` values, and multi-property `ORDER BY` without a declared composite index. Since 0.261.294 it also rejects reserved keywords (such as `group`, `value` or `order`) used as dotted property names or aliases in every Control Center query, classic routes included, and checks the generated Activity Logs search and group filters, which a static scan cannot see. Playwright coverage verifies capability-based section visibility and that the removed Data health section stays absent, including for its old URL. Top activity and token rankings are limited to entities present in the recorded `user_id` and workspace-context fields; unlogged historical status snapshots and model/provider details are not inferred. +Functional checks cover dashboard status normalization, period deltas, cache expiry and refresh, dashboard-reader access, capability parity, bootstrap exposure, and route wiring. Route-level tests run the real Dashboard and Users handlers, and the Groups inventory, against fake containers that reject query shapes the Python Cosmos SDK cannot run. `functional_tests/test_v2_control_center_cosmos_query_compatibility.py` scans every V2 Control Center SQL string for cross-partition `GROUP BY`, `COUNT` over `DISTINCT` values, and multi-property `ORDER BY` without a declared composite index. Since 0.261.296 it also rejects reserved keywords (such as `group`, `value` or `order`) used as dotted property names or aliases in every Control Center query, classic routes included, and checks the generated Activity Logs search and group filters, which a static scan cannot see. Playwright coverage verifies capability-based section visibility and that the removed Data health section stays absent, including for its old URL. Top activity and token rankings are limited to entities present in the recorded `user_id` and workspace-context fields; unlogged historical status snapshots and model/provider details are not inferred. ## Version tracking -The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278**, the dashboard in **0.261.279**, user management in **0.261.280**, group management in **0.261.282**, public workspace management in **0.261.283**, and Activity Logs in **0.261.284**. In **0.261.292**, the Dashboard, Users and Groups queries were made compatible with the Python Cosmos SDK. The same release removed the Data health section and its `GET /api/admin/control-center/migrate/status` and `POST /api/admin/control-center/migrate/all` backfill APIs; the classic Control Center had already stopped using them. In **0.261.294**, the Groups list, group details, the Activity Logs group filter, Activity Logs search and the classic group activity timeline stopped failing on the reserved `group` keyword (see [V2 Control Center Reserved Keyword Query Fix](../fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md)), and Activity Logs was redesigned around filter pills, names and account-backed saved views. +The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278**, the dashboard in **0.261.279**, user management in **0.261.280**, group management in **0.261.282**, public workspace management in **0.261.283**, and Activity Logs in **0.261.284**. In **0.261.292**, the Dashboard, Users and Groups queries were made compatible with the Python Cosmos SDK. The same release removed the Data health section and its `GET /api/admin/control-center/migrate/status` and `POST /api/admin/control-center/migrate/all` backfill APIs; the classic Control Center had already stopped using them. In **0.261.296**, the Groups list, group details, the Activity Logs group filter, Activity Logs search and the classic group activity timeline stopped failing on the reserved `group` keyword (see [V2 Control Center Reserved Keyword Query Fix](../fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md)), and Activity Logs was redesigned around filter pills, names and account-backed saved views. diff --git a/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md b/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md index 8425dc18f..7955ea5dc 100644 --- a/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md +++ b/docs/explanation/fixes/V2_CONTROL_CENTER_COSMOS_QUERY_COMPATIBILITY_FIX.md @@ -69,6 +69,6 @@ The response contracts are unchanged. Each unsupported query is replaced with a The V2 Data health section was removed in the same release, together with the activity-log backfill APIs that only it used: `GET /api/admin/control-center/migrate/status` and `POST /api/admin/control-center/migrate/all`. See [V2 Control Center](../features/V2_CONTROL_CENTER.md) and [Activity Log Migration Prompt Fix](ACTIVITY_LOG_MIGRATION_PROMPT_FIX.md). -## Follow-up in 0.261.294 +## Follow-up in 0.261.296 Groups still failed after this fix. The inventory's latest-activity query also read the nested `group` object as `c.group.group_id`, and `GROUP` is a reserved Cosmos SQL keyword, so Cosmos rejected the query with the same HTTP 400. See [V2 Control Center Reserved Keyword Query Fix](V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md). diff --git a/docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md b/docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md index 6ccaab18f..03f5f3d81 100644 --- a/docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md +++ b/docs/explanation/fixes/V2_CONTROL_CENTER_RESERVED_KEYWORD_QUERY_FIX.md @@ -1,6 +1,6 @@ # V2 Control Center Reserved Keyword Query Fix -**Fixed in version:** 0.261.294 +**Fixed in version:** 0.261.296 ## Issue @@ -52,7 +52,7 @@ Reserved segments are now written with the quoted property accessor, which Cosmo - `functional_tests/test_support/cosmos_query_guard.py`: `reserved_word_problems()` rejects reserved keywords used as dotted property names or aliases; `cosmos_query_problems()` includes it. - `functional_tests/test_v2_control_center_cosmos_query_compatibility.py`: applies the reserved-word check to every SQL string in `route_backend_control_center.py` and the `functions_control_center_*` modules, classic routes included, and to the generated Activity Logs search and group filters. - `functional_tests/test_v2_control_center_groups.py` and `functional_tests/test_v2_control_center_activity_logs_queries.py`: assert the bracketed form, run generated queries through the guard, and add a malformed legacy group case. -- `application/single_app/config.py`: version 0.261.294. +- `application/single_app/config.py`: version 0.261.296. ## Validation diff --git a/docs/guides/v2-control-center.md b/docs/guides/v2-control-center.md index 974d898d0..850a7a79c 100644 --- a/docs/guides/v2-control-center.md +++ b/docs/guides/v2-control-center.md @@ -44,7 +44,7 @@ Requesting group deletion, deleting all group documents, taking ownership, or tr ## Investigate activity -Activity Logs was implemented in **0.261.284** and redesigned in **0.261.294**. Open it from a dashboard chart, a user/workspace activity link, or the section rail. It answers who did what, where and when, with people and workspaces shown by name rather than by ID. +Activity Logs was implemented in **0.261.284** and redesigned in **0.261.296**. Open it from a dashboard chart, a user/workspace activity link, or the section rail. It answers who did what, where and when, with people and workspaces shown by name rather than by ID. ### Narrow the log with filter pills @@ -69,7 +69,7 @@ The record drawer shows what happened, **Who** (with **Show only this person's a ### Save and reuse views -**Views** opens quick views for recent sign-ins, recent token usage and document processing failures, and your saved views. **Save the current filters as** stores the current filters under a name; saving with an existing name replaces that view. Saved views live on your account, so they are there in any browser, and you can rename or delete them from the same menu. Views you saved in a browser before 0.261.294 move to your account the first time you open Activity Logs in that browser. If your settings cannot be loaded, the menu shows only the quick views until you reload the page, so a save cannot overwrite views it could not read. +**Views** opens quick views for recent sign-ins, recent token usage and document processing failures, and your saved views. **Save the current filters as** stores the current filters under a name; saving with an existing name replaces that view. Saved views live on your account, so they are there in any browser, and you can rename or delete them from the same menu. Views you saved in a browser before 0.261.296 move to your account the first time you open Activity Logs in that browser. If your settings cannot be loaded, the menu shows only the quick views until you reload the page, so a save cannot overwrite views it could not read. ### Export diff --git a/functional_tests/test_support/cosmos_query_guard.py b/functional_tests/test_support/cosmos_query_guard.py index 99780a291..c064b7173 100644 --- a/functional_tests/test_support/cosmos_query_guard.py +++ b/functional_tests/test_support/cosmos_query_guard.py @@ -8,7 +8,7 @@ property also fails with HTTP 400 unless the container declares a matching composite index. Fake containers accept any SQL, so tests run their queries through this guard. -Since 0.261.294 the guard also rejects reserved keywords used as dotted property names or +Since 0.261.296 the guard also rejects reserved keywords used as dotted property names or aliases, such as ``c.group.group_id`` or ``AS value``. The query grammar accepts only ALL, FIRST and LAST there, so Cosmos answers any other keyword with an HTTP 400 syntax error. The property must be written with brackets instead: ``c['group']['group_id']``. diff --git a/functional_tests/test_v2_control_center_activity_display.py b/functional_tests/test_v2_control_center_activity_display.py index 2b90a9773..1c16c47e8 100644 --- a/functional_tests/test_v2_control_center_activity_display.py +++ b/functional_tests/test_v2_control_center_activity_display.py @@ -2,8 +2,8 @@ # test_v2_control_center_activity_display.py """ Functional tests for the V2 Activity Logs presentation: labels, summaries and names. -Version: 0.261.294 -Implemented in: 0.261.294 +Version: 0.261.296 +Implemented in: 0.261.296 The V2 Activity Logs table showed raw user and group IDs, so administrators could not tell who did what or filter by a person they knew by name. The classic Control Center resolved @@ -290,4 +290,4 @@ def test_people_and_workspace_searches_are_parameterized_ranked_and_bounded(): def test_version_is_at_least_the_implementation_version(): - assert_app_version_at_least("0.261.294") + assert_app_version_at_least("0.261.296") diff --git a/functional_tests/test_v2_control_center_activity_logs_queries.py b/functional_tests/test_v2_control_center_activity_logs_queries.py index 75ecb41c1..f9bacd40a 100644 --- a/functional_tests/test_v2_control_center_activity_logs_queries.py +++ b/functional_tests/test_v2_control_center_activity_logs_queries.py @@ -1,12 +1,12 @@ # test_v2_control_center_activity_logs_queries.py """ Functional tests for bounded Control Center activity queries, paging and export. -Version: 0.261.294 +Version: 0.261.296 Implemented in: 0.261.284 Executes the actual dependency-neutral helper module with a query-contract storage fake. No cloud calls, Flask bootstrap replacement, or production module mutations. -Since 0.261.294 every generated query also passes the Cosmos query guard, which rejects +Since 0.261.296 every generated query also passes the Cosmos query guard, which rejects reserved keywords used as dotted property names: the search field group.group_name made every Activity Logs search fail with an HTTP 400 syntax error. """ diff --git a/functional_tests/test_v2_control_center_activity_logs_routes.py b/functional_tests/test_v2_control_center_activity_logs_routes.py index 53d532e06..b1e216a44 100644 --- a/functional_tests/test_v2_control_center_activity_logs_routes.py +++ b/functional_tests/test_v2_control_center_activity_logs_routes.py @@ -1,12 +1,12 @@ # test_v2_control_center_activity_logs_routes.py """ Functional tests for the Activity Logs HTTP and capability contracts. -Version: 0.261.294 +Version: 0.261.296 Implemented in: 0.261.284 Registers the actual new handlers with real authentication/capability decorators, isolating unrelated Azure bootstrap. Checks authorization before query execution. -Since 0.261.294 the page returns readable presentation and names for its filters, search +Since 0.261.296 the page returns readable presentation and names for its filters, search also matches people, and two lookups back the person and workspace filters. """ diff --git a/functional_tests/test_v2_control_center_cosmos_query_compatibility.py b/functional_tests/test_v2_control_center_cosmos_query_compatibility.py index e9f9bcbc9..b346439ae 100644 --- a/functional_tests/test_v2_control_center_cosmos_query_compatibility.py +++ b/functional_tests/test_v2_control_center_cosmos_query_compatibility.py @@ -2,7 +2,7 @@ # test_v2_control_center_cosmos_query_compatibility.py """ Functional test for V2 Control Center Cosmos query compatibility. -Version: 0.261.294 +Version: 0.261.296 Implemented in: 0.261.292 The Dashboard, Users and Groups sections returned HTTP 500 because Cosmos DB rejected @@ -11,7 +11,7 @@ index that user_settings does not have. This test scans every SQL string in the V2 Control Center code paths and fails if one of those query shapes returns. -Since 0.261.294 it also rejects reserved keywords used as dotted property names, such as +Since 0.261.296 it also rejects reserved keywords used as dotted property names, such as c.group.group_id, in every Control Center query, classic routes included. Cosmos answers those with an HTTP 400 syntax error, which kept the Groups list, group details, the Activity Logs group filter and every Activity Logs search failing after 0.261.292. @@ -238,7 +238,7 @@ def test_scan_covers_the_previously_failing_sections(): def test_version_is_at_least_the_implementation_version(): - assert_app_version_at_least("0.261.294") + assert_app_version_at_least("0.261.296") TESTS = [ diff --git a/functional_tests/test_v2_control_center_groups.py b/functional_tests/test_v2_control_center_groups.py index ce87372aa..fb29e92f1 100644 --- a/functional_tests/test_v2_control_center_groups.py +++ b/functional_tests/test_v2_control_center_groups.py @@ -1,7 +1,7 @@ # test_v2_control_center_groups.py """ Functional tests for V2 Control Center Groups. -Version: 0.261.294 +Version: 0.261.296 Implemented in: 0.261.282 Run real filters and routes over isolated Cosmos services and the real guarded @@ -9,7 +9,7 @@ admin-only access, snapshot expiry, safe exports and approval-only actions. Since 0.261.292 the inventory fakes reject GROUP BY, because the Python Cosmos SDK cannot run it across partitions; the inventory aggregates streamed projections. -Since 0.261.294 they also reject reserved keywords used as dotted property names: +Since 0.261.296 they also reject reserved keywords used as dotted property names: c.group.group_id is an HTTP 400 syntax error, so the nested shape is read as c['group']. """ diff --git a/ui_tests/test_v2_control_center_activity_logs.py b/ui_tests/test_v2_control_center_activity_logs.py index 5d822082e..e325a16a4 100644 --- a/ui_tests/test_v2_control_center_activity_logs.py +++ b/ui_tests/test_v2_control_center_activity_logs.py @@ -1,13 +1,13 @@ # test_v2_control_center_activity_logs.py """ Browser coverage for V2 Activity Logs. -Version: 0.261.294 +Version: 0.261.296 Implemented in: 0.261.284 -Redesigned in: 0.261.294 +Redesigned in: 0.261.296 Uses local built assets and intercepted APIs, with the shared Azure Playwright connection helper when a workspace is configured. Covers desktop and mobile. -Since 0.261.294 the page leads with Azure-portal-style filter pills, shows people and +Since 0.261.296 the page leads with Azure-portal-style filter pills, shows people and workspaces by name, cross-filters from any person, activity or workspace in a row, keeps saved views and display preferences on the account, and shows times in local time with a remembered UTC toggle. @@ -156,7 +156,7 @@ def _route(self, route): }) elif path == "/api/v2/bootstrap" and not self.allowed: route.fulfill(json={ - "version": "0.261.294", "user": {"id": "reader", "display_name": "Reader", "is_admin": False, "roles": ["ControlCenterDashboardReader"]}, + "version": "0.261.296", "user": {"id": "reader", "display_name": "Reader", "is_admin": False, "roles": ["ControlCenterDashboardReader"]}, "branding": {"app_title": "SimpleChat", "show_logo": False}, "features": {}, "control_center": {"can_view_dashboard": True, "can_manage_users": False, "can_manage_groups": False, "can_manage_workspaces": False, "can_view_activity_logs": False, "can_run_maintenance": False},