Compare commits

..

4 Commits

Author SHA1 Message Date
dependabot[bot]
f66891d19e chore(deps-dev): bump handlebars from 4.7.8 to 4.7.9 in /web (#9689)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-27 01:41:29 +00:00
Nikolas Garza
c07c952ad5 chore(greptile): add nginx routing rule for non-api backend routes (#9687) 2026-03-27 00:34:15 +00:00
Nikolas Garza
be7f40a28a fix(nginx): route /scim/* to api_server (#9686) 2026-03-26 17:21:57 -07:00
Evan Lohn
26f941b5da perf: perm sync start time (#9685) 2026-03-27 00:07:53 +00:00
22 changed files with 239 additions and 1110 deletions

View File

@@ -24,6 +24,16 @@ When hardcoding a boolean variable to a constant value, remove the variable enti
Code changes must consider both multi-tenant and single-tenant deployments. In multi-tenant mode, preserve tenant isolation, ensure tenant context is propagated correctly, and avoid assumptions that only hold for a single shared schema or globally shared state. In single-tenant mode, avoid introducing unnecessary tenant-specific requirements or cloud-only control-plane dependencies.
## Nginx Routing — New Backend Routes
Whenever a new backend route is added that does NOT start with `/api`, it must also be explicitly added to ALL nginx configs:
- `deployment/helm/charts/onyx/templates/nginx-conf.yaml` (Helm/k8s)
- `deployment/data/nginx/app.conf.template` (docker-compose dev)
- `deployment/data/nginx/app.conf.template.prod` (docker-compose prod)
- `deployment/data/nginx/app.conf.template.no-letsencrypt` (docker-compose no-letsencrypt)
Routes not starting with `/api` are not caught by the existing `^/(api|openapi\.json)` location block and will fall through to `location /`, which proxies to the Next.js web server and returns an HTML 404. The new location block must be placed before the `/api` block. Examples of routes that need this treatment: `/scim`, `/mcp`.
## Full vs Lite Deployments
Code changes must consider both regular Onyx deployments and Onyx lite deployments. Lite deployments disable the vector DB, Redis, model servers, and background workers by default, use PostgreSQL-backed cache/auth/file storage, and rely on the API server to handle background work. Do not assume those services are available unless the code path is explicitly limited to full deployments.

View File

@@ -473,6 +473,8 @@ def connector_permission_sync_generator_task(
cc_pair = get_connector_credential_pair_from_id(
db_session=db_session,
cc_pair_id=cc_pair_id,
eager_load_connector=True,
eager_load_credential=True,
)
if cc_pair is None:
raise ValueError(

View File

@@ -8,6 +8,7 @@ from ee.onyx.external_permissions.slack.utils import fetch_user_id_to_email_map
from onyx.access.models import DocExternalAccess
from onyx.access.models import ExternalAccess
from onyx.connectors.credentials_provider import OnyxDBCredentialsProvider
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
from onyx.connectors.models import HierarchyNode
from onyx.connectors.slack.connector import get_channels
from onyx.connectors.slack.connector import make_paginated_slack_api_call
@@ -105,9 +106,11 @@ def _get_slack_document_access(
slack_connector: SlackConnector,
channel_permissions: dict[str, ExternalAccess], # noqa: ARG001
callback: IndexingHeartbeatInterface | None,
indexing_start: SecondsSinceUnixEpoch | None = None,
) -> Generator[DocExternalAccess, None, None]:
slim_doc_generator = slack_connector.retrieve_all_slim_docs_perm_sync(
callback=callback
callback=callback,
start=indexing_start,
)
for doc_metadata_batch in slim_doc_generator:
@@ -180,9 +183,15 @@ def slack_doc_sync(
slack_connector = SlackConnector(**cc_pair.connector.connector_specific_config)
slack_connector.set_credentials_provider(provider)
indexing_start_ts: SecondsSinceUnixEpoch | None = (
cc_pair.connector.indexing_start.timestamp()
if cc_pair.connector.indexing_start is not None
else None
)
yield from _get_slack_document_access(
slack_connector,
slack_connector=slack_connector,
channel_permissions=channel_permissions,
callback=callback,
indexing_start=indexing_start_ts,
)

View File

@@ -6,6 +6,7 @@ from onyx.access.models import ElementExternalAccess
from onyx.access.models import ExternalAccess
from onyx.access.models import NodeExternalAccess
from onyx.configs.constants import DocumentSource
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
from onyx.connectors.interfaces import SlimConnectorWithPermSync
from onyx.connectors.models import HierarchyNode
from onyx.db.models import ConnectorCredentialPair
@@ -40,10 +41,19 @@ def generic_doc_sync(
logger.info(f"Starting {doc_source} doc sync for CC Pair ID: {cc_pair.id}")
indexing_start: SecondsSinceUnixEpoch | None = (
cc_pair.connector.indexing_start.timestamp()
if cc_pair.connector.indexing_start is not None
else None
)
newly_fetched_doc_ids: set[str] = set()
logger.info(f"Fetching all slim documents from {doc_source}")
for doc_batch in slim_connector.retrieve_all_slim_docs_perm_sync(callback=callback):
for doc_batch in slim_connector.retrieve_all_slim_docs_perm_sync(
start=indexing_start,
callback=callback,
):
logger.info(f"Got {len(doc_batch)} slim documents from {doc_source}")
if callback:

View File

@@ -890,8 +890,8 @@ class ConfluenceConnector(
def _retrieve_all_slim_docs(
self,
start: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
end: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
start: SecondsSinceUnixEpoch | None = None,
end: SecondsSinceUnixEpoch | None = None,
callback: IndexingHeartbeatInterface | None = None,
include_permissions: bool = True,
) -> GenerateSlimDocumentOutput:
@@ -915,8 +915,8 @@ class ConfluenceConnector(
self.confluence_client, doc_id, restrictions, ancestors
) or space_level_access_info.get(page_space_key)
# Query pages
page_query = self.base_cql_page_query + self.cql_label_filter
# Query pages (with optional time filtering for indexing_start)
page_query = self._construct_page_cql_query(start, end)
for page in self.confluence_client.cql_paginate_all_expansions(
cql=page_query,
expand=restrictions_expand,
@@ -950,7 +950,9 @@ class ConfluenceConnector(
# Query attachments for each page
page_hierarchy_node_yielded = False
attachment_query = self._construct_attachment_query(_get_page_id(page))
attachment_query = self._construct_attachment_query(
_get_page_id(page), start, end
)
for attachment in self.confluence_client.cql_paginate_all_expansions(
cql=attachment_query,
expand=restrictions_expand,

View File

@@ -1765,7 +1765,11 @@ class SharepointConnector(
checkpoint.current_drive_delta_next_link = None
checkpoint.seen_document_ids.clear()
def _fetch_slim_documents_from_sharepoint(self) -> GenerateSlimDocumentOutput:
def _fetch_slim_documents_from_sharepoint(
self,
start: datetime | None = None,
end: datetime | None = None,
) -> GenerateSlimDocumentOutput:
site_descriptors = self._filter_excluded_sites(
self.site_descriptors or self.fetch_sites()
)
@@ -1786,7 +1790,9 @@ class SharepointConnector(
# Process site documents if flag is True
if self.include_site_documents:
for driveitem, drive_name, drive_web_url in self._fetch_driveitems(
site_descriptor=site_descriptor
site_descriptor=site_descriptor,
start=start,
end=end,
):
if self._is_driveitem_excluded(driveitem):
logger.debug(f"Excluding by path denylist: {driveitem.web_url}")
@@ -1841,7 +1847,9 @@ class SharepointConnector(
# Process site pages if flag is True
if self.include_site_pages:
site_pages = self._fetch_site_pages(site_descriptor)
site_pages = self._fetch_site_pages(
site_descriptor, start=start, end=end
)
for site_page in site_pages:
logger.debug(
f"Processing site page: {site_page.get('webUrl', site_page.get('name', 'Unknown'))}"
@@ -2565,12 +2573,22 @@ class SharepointConnector(
def retrieve_all_slim_docs_perm_sync(
self,
start: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
end: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
start: SecondsSinceUnixEpoch | None = None,
end: SecondsSinceUnixEpoch | None = None,
callback: IndexingHeartbeatInterface | None = None, # noqa: ARG002
) -> GenerateSlimDocumentOutput:
yield from self._fetch_slim_documents_from_sharepoint()
start_dt = (
datetime.fromtimestamp(start, tz=timezone.utc)
if start is not None
else None
)
end_dt = (
datetime.fromtimestamp(end, tz=timezone.utc) if end is not None else None
)
yield from self._fetch_slim_documents_from_sharepoint(
start=start_dt,
end=end_dt,
)
if __name__ == "__main__":

View File

@@ -516,6 +516,8 @@ def _get_all_doc_ids(
] = default_msg_filter,
callback: IndexingHeartbeatInterface | None = None,
workspace_url: str | None = None,
start: SecondsSinceUnixEpoch | None = None,
end: SecondsSinceUnixEpoch | None = None,
) -> GenerateSlimDocumentOutput:
"""
Get all document ids in the workspace, channel by channel
@@ -546,6 +548,8 @@ def _get_all_doc_ids(
client=client,
channel=channel,
callback=callback,
oldest=str(start) if start else None, # 0.0 -> None intentionally
latest=str(end) if end is not None else None,
)
for message_batch in channel_message_batches:
@@ -847,8 +851,8 @@ class SlackConnector(
def retrieve_all_slim_docs_perm_sync(
self,
start: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
end: SecondsSinceUnixEpoch | None = None, # noqa: ARG002
start: SecondsSinceUnixEpoch | None = None,
end: SecondsSinceUnixEpoch | None = None,
callback: IndexingHeartbeatInterface | None = None,
) -> GenerateSlimDocumentOutput:
if self.client is None:
@@ -861,6 +865,8 @@ class SlackConnector(
msg_filter_func=self.msg_filter_func,
callback=callback,
workspace_url=self._workspace_url,
start=start,
end=end,
)
def _load_from_checkpoint(

View File

@@ -1,4 +1,6 @@
import time
from datetime import datetime
from datetime import timezone
import pytest
@@ -17,6 +19,10 @@ PRIVATE_CHANNEL_USERS = [
"test_user_2@onyx-test.com",
]
# Predates any test workspace messages, so the result set should match
# the "no start time" case while exercising the oldest= parameter.
OLDEST_TS_2016 = datetime(2016, 1, 1, tzinfo=timezone.utc).timestamp()
pytestmark = pytest.mark.usefixtures("enable_ee")
@@ -105,15 +111,17 @@ def test_load_from_checkpoint_access__private_channel(
],
indirect=True,
)
@pytest.mark.parametrize("start_ts", [None, OLDEST_TS_2016])
def test_slim_documents_access__public_channel(
slack_connector: SlackConnector,
start_ts: float | None,
) -> None:
"""Test that retrieve_all_slim_docs_perm_sync returns correct access information for slim documents."""
if not slack_connector.client:
raise RuntimeError("Web client must be defined")
slim_docs_generator = slack_connector.retrieve_all_slim_docs_perm_sync(
start=0.0,
start=start_ts,
end=time.time(),
)
@@ -149,7 +157,7 @@ def test_slim_documents_access__private_channel(
raise RuntimeError("Web client must be defined")
slim_docs_generator = slack_connector.retrieve_all_slim_docs_perm_sync(
start=0.0,
start=None,
end=time.time(),
)

View File

@@ -1,3 +1,5 @@
from datetime import datetime
from datetime import timezone
from unittest.mock import MagicMock
from unittest.mock import patch
@@ -31,6 +33,7 @@ def mock_jira_cc_pair(
"jira_base_url": jira_base_url,
"project_key": project_key,
}
mock_cc_pair.connector.indexing_start = None
return mock_cc_pair
@@ -65,3 +68,75 @@ def test_jira_permission_sync(
fetch_all_existing_docs_ids_fn=mock_fetch_all_existing_docs_ids_fn,
):
print(doc)
def test_jira_doc_sync_passes_indexing_start(
jira_connector: JiraConnector,
mock_jira_cc_pair: MagicMock,
mock_fetch_all_existing_docs_fn: MagicMock,
mock_fetch_all_existing_docs_ids_fn: MagicMock,
) -> None:
"""Verify that generic_doc_sync derives indexing_start from cc_pair
and forwards it to retrieve_all_slim_docs_perm_sync."""
indexing_start_dt = datetime(2025, 6, 1, tzinfo=timezone.utc)
mock_jira_cc_pair.connector.indexing_start = indexing_start_dt
with patch("onyx.connectors.jira.connector.build_jira_client") as mock_build_client:
mock_build_client.return_value = jira_connector._jira_client
assert jira_connector._jira_client is not None
jira_connector._jira_client._options = MagicMock()
jira_connector._jira_client._options.return_value = {
"rest_api_version": JIRA_SERVER_API_VERSION
}
with patch.object(
type(jira_connector),
"retrieve_all_slim_docs_perm_sync",
return_value=iter([]),
) as mock_retrieve:
list(
jira_doc_sync(
cc_pair=mock_jira_cc_pair,
fetch_all_existing_docs_fn=mock_fetch_all_existing_docs_fn,
fetch_all_existing_docs_ids_fn=mock_fetch_all_existing_docs_ids_fn,
)
)
mock_retrieve.assert_called_once()
call_kwargs = mock_retrieve.call_args
assert call_kwargs.kwargs["start"] == indexing_start_dt.timestamp()
def test_jira_doc_sync_passes_none_when_no_indexing_start(
jira_connector: JiraConnector,
mock_jira_cc_pair: MagicMock,
mock_fetch_all_existing_docs_fn: MagicMock,
mock_fetch_all_existing_docs_ids_fn: MagicMock,
) -> None:
"""Verify that indexing_start is None when the connector has no indexing_start set."""
mock_jira_cc_pair.connector.indexing_start = None
with patch("onyx.connectors.jira.connector.build_jira_client") as mock_build_client:
mock_build_client.return_value = jira_connector._jira_client
assert jira_connector._jira_client is not None
jira_connector._jira_client._options = MagicMock()
jira_connector._jira_client._options.return_value = {
"rest_api_version": JIRA_SERVER_API_VERSION
}
with patch.object(
type(jira_connector),
"retrieve_all_slim_docs_perm_sync",
return_value=iter([]),
) as mock_retrieve:
list(
jira_doc_sync(
cc_pair=mock_jira_cc_pair,
fetch_all_existing_docs_fn=mock_fetch_all_existing_docs_fn,
fetch_all_existing_docs_ids_fn=mock_fetch_all_existing_docs_ids_fn,
)
)
mock_retrieve.assert_called_once()
call_kwargs = mock_retrieve.call_args
assert call_kwargs.kwargs["start"] is None

View File

@@ -39,6 +39,22 @@ server {
# Conditionally include MCP location configuration
include /etc/nginx/conf.d/mcp.conf.inc;
location ~ ^/scim(/.*)?$ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_redirect off;
proxy_connect_timeout ${NGINX_PROXY_CONNECT_TIMEOUT}s;
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT}s;
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT}s;
proxy_pass http://api_server;
}
# Match both /api/* and /openapi.json in a single rule
location ~ ^/(api|openapi.json)(/.*)?$ {
# Rewrite /api prefixed matched paths

View File

@@ -39,6 +39,20 @@ server {
# Conditionally include MCP location configuration
include /etc/nginx/conf.d/mcp.conf.inc;
location ~ ^/scim(/.*)?$ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# don't trust client-supplied X-Forwarded-* headers — use nginx's own values
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_redirect off;
proxy_pass http://api_server;
}
# Match both /api/* and /openapi.json in a single rule
location ~ ^/(api|openapi.json)(/.*)?$ {
# Rewrite /api prefixed matched paths

View File

@@ -39,6 +39,23 @@ server {
# Conditionally include MCP location configuration
include /etc/nginx/conf.d/mcp.conf.inc;
location ~ ^/scim(/.*)?$ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# don't trust client-supplied X-Forwarded-* headers — use nginx's own values
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_redirect off;
proxy_connect_timeout ${NGINX_PROXY_CONNECT_TIMEOUT}s;
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT}s;
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT}s;
proxy_pass http://api_server;
}
# Match both /api/* and /openapi.json in a single rule
location ~ ^/(api|openapi.json)(/.*)?$ {
# Rewrite /api prefixed matched paths

View File

@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.4.36
version: 0.4.37
appVersion: latest
annotations:
category: Productivity

View File

@@ -63,6 +63,22 @@ data:
}
{{- end }}
location ~ ^/scim(/.*)?$ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_buffering off;
proxy_redirect off;
# timeout settings
proxy_connect_timeout {{ .Values.nginx.timeouts.connect }}s;
proxy_send_timeout {{ .Values.nginx.timeouts.send }}s;
proxy_read_timeout {{ .Values.nginx.timeouts.read }}s;
proxy_pass http://api_server;
}
location ~ ^/(api|openapi\.json)(/.*)?$ {
rewrite ^/api(/.*)$ $1 break;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -282,7 +282,7 @@ nginx:
# The ingress-nginx subchart doesn't auto-detect our custom ConfigMap changes.
# Workaround: Helm upgrade will restart if the following annotation value changes.
podAnnotations:
onyx.app/nginx-config-version: "2"
onyx.app/nginx-config-version: "3"
# Propagate DOMAIN into nginx so server_name continues to use the same env var
extraEnvs:

View File

@@ -28,7 +28,7 @@ Some commands require external tools to be installed and configured:
- **uv** - Required for `backend` commands
- Install from [docs.astral.sh/uv](https://docs.astral.sh/uv/)
- **GitHub CLI** (`gh`) - Required for `run-ci`, `cherry-pick`, and `trace` commands
- **GitHub CLI** (`gh`) - Required for `run-ci` and `cherry-pick` commands
- Install from [cli.github.com](https://cli.github.com/)
- Authenticate with `gh auth login`
@@ -412,62 +412,6 @@ The `compare` subcommand writes a `summary.json` alongside the report with aggre
counts (changed, added, removed, unchanged). The HTML report is only generated when
visual differences are detected.
### `trace` - View Playwright Traces from CI
Download Playwright trace artifacts from a GitHub Actions run and open them
with `playwright show-trace`. Traces are only generated for failing tests
(`retain-on-failure`).
```shell
ods trace [run-id-or-url]
```
The run can be specified as a numeric run ID, a full GitHub Actions URL, or
omitted to find the latest Playwright run for the current branch.
**Flags:**
| Flag | Default | Description |
|------|---------|-------------|
| `--branch`, `-b` | | Find latest run for this branch |
| `--pr` | | Find latest run for this PR number |
| `--project`, `-p` | | Filter to a specific project (`admin`, `exclusive`, `lite`) |
| `--list`, `-l` | `false` | List available traces without opening |
| `--no-open` | `false` | Download traces but don't open them |
When multiple traces are found, an interactive picker lets you select which
traces to open. Use arrow keys or `j`/`k` to navigate, `space` to toggle,
`a` to select all, `n` to deselect all, and `enter` to open. Falls back to a
plain-text prompt when no TTY is available.
Downloaded artifacts are cached in `/tmp/ods-traces/<run-id>/` so repeated
invocations for the same run are instant.
**Examples:**
```shell
# Latest run for the current branch
ods trace
# Specific run ID
ods trace 12345678
# Full GitHub Actions URL
ods trace https://github.com/onyx-dot-app/onyx/actions/runs/12345678
# Latest run for a PR
ods trace --pr 9500
# Latest run for a specific branch
ods trace --branch main
# Only download admin project traces
ods trace --project admin
# List traces without opening
ods trace --list
```
### Testing Changes Locally (Dry Run)
Both `run-ci` and `cherry-pick` support `--dry-run` to test without making remote changes:

View File

@@ -55,7 +55,6 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewWebCommand())
cmd.AddCommand(NewLatestStableTagCommand())
cmd.AddCommand(NewWhoisCommand())
cmd.AddCommand(NewTraceCommand())
return cmd
}

View File

@@ -1,556 +0,0 @@
package cmd
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/git"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
"github.com/onyx-dot-app/onyx/tools/ods/internal/tui"
)
const playwrightWorkflow = "Run Playwright Tests"
// TraceOptions holds options for the trace command
type TraceOptions struct {
Branch string
PR string
Project string
List bool
NoOpen bool
}
// traceInfo describes a single trace.zip found in the downloaded artifacts.
type traceInfo struct {
Path string // absolute path to trace.zip
Project string // project group extracted from artifact dir (e.g. "admin", "admin-shard-1")
TestDir string // test directory name (human-readable-ish)
}
// NewTraceCommand creates a new trace command
func NewTraceCommand() *cobra.Command {
opts := &TraceOptions{}
cmd := &cobra.Command{
Use: "trace [run-id-or-url]",
Short: "Download and view Playwright traces from GitHub Actions",
Long: `Download Playwright trace artifacts from a GitHub Actions run and open them
with 'playwright show-trace'.
The run can be specified as:
- A GitHub Actions run ID (numeric)
- A full GitHub Actions run URL
- Omitted, to find the latest Playwright run for the current branch
You can also look up the latest run by branch name or PR number.
Examples:
ods trace # latest run for current branch
ods trace 12345678 # specific run ID
ods trace https://github.com/onyx-dot-app/onyx/actions/runs/12345678
ods trace --pr 9500 # latest run for PR #9500
ods trace --branch main # latest run for main branch
ods trace --project admin # only download admin project traces
ods trace --list # list available traces without opening`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runTrace(args, opts)
},
}
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Find latest run for this branch")
cmd.Flags().StringVar(&opts.PR, "pr", "", "Find latest run for this PR number")
cmd.Flags().StringVarP(&opts.Project, "project", "p", "", "Filter to a specific project (admin, exclusive, lite)")
cmd.Flags().BoolVarP(&opts.List, "list", "l", false, "List available traces without opening")
cmd.Flags().BoolVar(&opts.NoOpen, "no-open", false, "Download traces but don't open them")
return cmd
}
// ghRun represents a GitHub Actions workflow run from `gh run list`
type ghRun struct {
DatabaseID int64 `json:"databaseId"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
HeadBranch string `json:"headBranch"`
URL string `json:"url"`
}
func runTrace(args []string, opts *TraceOptions) {
git.CheckGitHubCLI()
runID, err := resolveRunID(args, opts)
if err != nil {
log.Fatalf("Failed to resolve run: %v", err)
}
log.Infof("Using run ID: %s", runID)
destDir, err := downloadTraceArtifacts(runID, opts.Project)
if err != nil {
log.Fatalf("Failed to download artifacts: %v", err)
}
traces, err := findTraceInfos(destDir, runID)
if err != nil {
log.Fatalf("Failed to find traces: %v", err)
}
if len(traces) == 0 {
log.Info("No trace files found in the downloaded artifacts.")
log.Info("Traces are only generated for failing tests (retain-on-failure).")
return
}
projects := groupByProject(traces)
if opts.List || opts.NoOpen {
printTraceList(traces, projects)
fmt.Printf("\nTraces downloaded to: %s\n", destDir)
return
}
if len(traces) == 1 {
openTraces(traces)
return
}
for {
selected := selectTraces(traces, projects)
if len(selected) == 0 {
return
}
openTraces(selected)
}
}
// resolveRunID determines the run ID from the provided arguments and options.
func resolveRunID(args []string, opts *TraceOptions) (string, error) {
if len(args) == 1 {
return parseRunIDFromArg(args[0])
}
if opts.PR != "" {
return findLatestRunForPR(opts.PR)
}
branch := opts.Branch
if branch == "" {
var err error
branch, err = git.GetCurrentBranch()
if err != nil {
return "", fmt.Errorf("failed to get current branch: %w", err)
}
if branch == "" {
return "", fmt.Errorf("detached HEAD; specify a --branch, --pr, or run ID")
}
log.Infof("Using current branch: %s", branch)
}
return findLatestRunForBranch(branch)
}
var runURLPattern = regexp.MustCompile(`/actions/runs/(\d+)`)
// parseRunIDFromArg extracts a run ID from either a numeric string or a full URL.
func parseRunIDFromArg(arg string) (string, error) {
if matched, _ := regexp.MatchString(`^\d+$`, arg); matched {
return arg, nil
}
matches := runURLPattern.FindStringSubmatch(arg)
if matches != nil {
return matches[1], nil
}
return "", fmt.Errorf("could not parse run ID from %q; expected a numeric ID or GitHub Actions URL", arg)
}
// findLatestRunForBranch finds the most recent Playwright workflow run for a branch.
func findLatestRunForBranch(branch string) (string, error) {
log.Infof("Looking up latest Playwright run for branch: %s", branch)
cmd := exec.Command("gh", "run", "list",
"--workflow", playwrightWorkflow,
"--branch", branch,
"--limit", "1",
"--json", "databaseId,status,conclusion,headBranch,url",
)
output, err := cmd.Output()
if err != nil {
return "", ghError(err, "gh run list failed")
}
var runs []ghRun
if err := json.Unmarshal(output, &runs); err != nil {
return "", fmt.Errorf("failed to parse run list: %w", err)
}
if len(runs) == 0 {
return "", fmt.Errorf("no Playwright runs found for branch %q", branch)
}
run := runs[0]
log.Infof("Found run: %s (status: %s, conclusion: %s)", run.URL, run.Status, run.Conclusion)
return fmt.Sprintf("%d", run.DatabaseID), nil
}
// findLatestRunForPR finds the most recent Playwright workflow run for a PR.
func findLatestRunForPR(prNumber string) (string, error) {
log.Infof("Looking up branch for PR #%s", prNumber)
cmd := exec.Command("gh", "pr", "view", prNumber,
"--json", "headRefName",
"--jq", ".headRefName",
)
output, err := cmd.Output()
if err != nil {
return "", ghError(err, "gh pr view failed")
}
branch := strings.TrimSpace(string(output))
if branch == "" {
return "", fmt.Errorf("could not determine branch for PR #%s", prNumber)
}
log.Infof("PR #%s is on branch: %s", prNumber, branch)
return findLatestRunForBranch(branch)
}
// downloadTraceArtifacts downloads playwright trace artifacts for a run.
// Returns the path to the download directory.
func downloadTraceArtifacts(runID string, project string) (string, error) {
cacheKey := runID
if project != "" {
cacheKey = runID + "-" + project
}
destDir := filepath.Join(os.TempDir(), "ods-traces", cacheKey)
// Reuse a previous download if traces exist
if info, err := os.Stat(destDir); err == nil && info.IsDir() {
traces, _ := findTraces(destDir)
if len(traces) > 0 {
log.Infof("Using cached download at %s", destDir)
return destDir, nil
}
_ = os.RemoveAll(destDir)
}
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("failed to create directory %s: %w", destDir, err)
}
ghArgs := []string{"run", "download", runID, "--dir", destDir}
if project != "" {
ghArgs = append(ghArgs, "--pattern", fmt.Sprintf("playwright-test-results-%s-*", project))
} else {
ghArgs = append(ghArgs, "--pattern", "playwright-test-results-*")
}
log.Infof("Downloading trace artifacts...")
log.Debugf("Running: gh %s", strings.Join(ghArgs, " "))
cmd := exec.Command("gh", ghArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
_ = os.RemoveAll(destDir)
return "", fmt.Errorf("gh run download failed: %w\nMake sure the run ID is correct and the artifacts haven't expired (30 day retention)", err)
}
return destDir, nil
}
// findTraces recursively finds all trace.zip files under a directory.
func findTraces(root string) ([]string, error) {
var traces []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && info.Name() == "trace.zip" {
traces = append(traces, path)
}
return nil
})
return traces, err
}
// findTraceInfos walks the download directory and returns structured trace info.
// Expects: destDir/{artifact-dir}/{test-dir}/trace.zip
func findTraceInfos(destDir, runID string) ([]traceInfo, error) {
var traces []traceInfo
err := filepath.Walk(destDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || info.Name() != "trace.zip" {
return nil
}
rel, _ := filepath.Rel(destDir, path)
parts := strings.SplitN(rel, string(filepath.Separator), 3)
artifactDir := ""
testDir := filepath.Base(filepath.Dir(path))
if len(parts) >= 2 {
artifactDir = parts[0]
testDir = parts[1]
}
traces = append(traces, traceInfo{
Path: path,
Project: extractProject(artifactDir, runID),
TestDir: testDir,
})
return nil
})
sort.Slice(traces, func(i, j int) bool {
pi, pj := projectSortKey(traces[i].Project), projectSortKey(traces[j].Project)
if pi != pj {
return pi < pj
}
return traces[i].TestDir < traces[j].TestDir
})
return traces, err
}
// extractProject derives a project group from an artifact directory name.
// e.g. "playwright-test-results-admin-12345" -> "admin"
//
// "playwright-test-results-admin-shard-1-12345" -> "admin-shard-1"
func extractProject(artifactDir, runID string) string {
name := strings.TrimPrefix(artifactDir, "playwright-test-results-")
name = strings.TrimSuffix(name, "-"+runID)
if name == "" {
return artifactDir
}
return name
}
// projectSortKey returns a sort-friendly key that orders admin < exclusive < lite.
func projectSortKey(project string) string {
switch {
case strings.HasPrefix(project, "admin"):
return "0-" + project
case strings.HasPrefix(project, "exclusive"):
return "1-" + project
case strings.HasPrefix(project, "lite"):
return "2-" + project
default:
return "3-" + project
}
}
// groupByProject returns an ordered list of unique project names found in traces.
func groupByProject(traces []traceInfo) []string {
seen := map[string]bool{}
var projects []string
for _, t := range traces {
if !seen[t.Project] {
seen[t.Project] = true
projects = append(projects, t.Project)
}
}
sort.Slice(projects, func(i, j int) bool {
return projectSortKey(projects[i]) < projectSortKey(projects[j])
})
return projects
}
// printTraceList displays traces grouped by project.
func printTraceList(traces []traceInfo, projects []string) {
fmt.Printf("\nFound %d trace(s) across %d project(s):\n", len(traces), len(projects))
idx := 1
for _, proj := range projects {
count := 0
for _, t := range traces {
if t.Project == proj {
count++
}
}
fmt.Printf("\n %s (%d):\n", proj, count)
for _, t := range traces {
if t.Project == proj {
fmt.Printf(" [%2d] %s\n", idx, t.TestDir)
idx++
}
}
}
}
// selectTraces tries the TUI picker first, falling back to a plain-text
// prompt when the terminal cannot be initialised (e.g. piped output).
func selectTraces(traces []traceInfo, projects []string) []traceInfo {
// Build picker groups in the same order as the sorted traces slice.
var groups []tui.PickerGroup
for _, proj := range projects {
var items []string
for _, t := range traces {
if t.Project == proj {
items = append(items, t.TestDir)
}
}
groups = append(groups, tui.PickerGroup{Label: proj, Items: items})
}
indices, err := tui.Pick(groups)
if err != nil {
// Terminal not available — fall back to text prompt
log.Debugf("TUI picker unavailable: %v", err)
printTraceList(traces, projects)
return promptTraceSelection(traces, projects)
}
if indices == nil {
return nil // user cancelled
}
selected := make([]traceInfo, len(indices))
for i, idx := range indices {
selected[i] = traces[idx]
}
return selected
}
// promptTraceSelection asks the user which traces to open via plain text.
// Accepts numbers (1,3,5), ranges (1-5), "all", or a project name.
func promptTraceSelection(traces []traceInfo, projects []string) []traceInfo {
fmt.Printf("\nOpen which traces? (e.g. 1,3,5 | 1-5 | all | %s): ", strings.Join(projects, " | "))
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
log.Fatalf("Failed to read input: %v", err)
}
input = strings.TrimSpace(input)
if input == "" || strings.EqualFold(input, "all") {
return traces
}
// Check if input matches a project name
for _, proj := range projects {
if strings.EqualFold(input, proj) {
var selected []traceInfo
for _, t := range traces {
if t.Project == proj {
selected = append(selected, t)
}
}
return selected
}
}
// Parse as number/range selection
indices := parseTraceSelection(input, len(traces))
if len(indices) == 0 {
log.Warn("No valid selection; opening all traces")
return traces
}
selected := make([]traceInfo, len(indices))
for i, idx := range indices {
selected[i] = traces[idx]
}
return selected
}
// parseTraceSelection parses a comma-separated list of numbers and ranges into
// 0-based indices. Input is 1-indexed (matches display). Out-of-range values
// are silently ignored.
func parseTraceSelection(input string, max int) []int {
var result []int
seen := map[int]bool{}
for _, part := range strings.Split(input, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if idx := strings.Index(part, "-"); idx > 0 {
lo, err1 := strconv.Atoi(strings.TrimSpace(part[:idx]))
hi, err2 := strconv.Atoi(strings.TrimSpace(part[idx+1:]))
if err1 != nil || err2 != nil {
continue
}
for i := lo; i <= hi; i++ {
zi := i - 1
if zi >= 0 && zi < max && !seen[zi] {
result = append(result, zi)
seen[zi] = true
}
}
} else {
n, err := strconv.Atoi(part)
if err != nil {
continue
}
zi := n - 1
if zi >= 0 && zi < max && !seen[zi] {
result = append(result, zi)
seen[zi] = true
}
}
}
return result
}
// openTraces opens the selected traces with playwright show-trace,
// running npx from the web/ directory to use the project's Playwright version.
func openTraces(traces []traceInfo) {
tracePaths := make([]string, len(traces))
for i, t := range traces {
tracePaths[i] = t.Path
}
args := append([]string{"playwright", "show-trace"}, tracePaths...)
log.Infof("Opening %d trace(s) with playwright show-trace...", len(traces))
cmd := exec.Command("npx", args...)
// Run from web/ to pick up the locally-installed Playwright version
if root, err := paths.GitRoot(); err == nil {
cmd.Dir = filepath.Join(root, "web")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// Normal exit (e.g. user closed the window) — just log and return
// so the picker loop can continue.
log.Debugf("playwright exited with code %d", exitErr.ExitCode())
return
}
log.Errorf("playwright show-trace failed: %v\nMake sure Playwright is installed (npx playwright install)", err)
}
}
// ghError wraps a gh CLI error with stderr output.
func ghError(err error, msg string) error {
if exitErr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf("%s: %w: %s", msg, err, string(exitErr.Stderr))
}
return fmt.Errorf("%s: %w", msg, err)
}

View File

@@ -3,19 +3,13 @@ module github.com/onyx-dot-app/onyx/tools/ods
go 1.26.0
require (
github.com/gdamore/tcell/v2 v2.13.8
github.com/jmelahman/tag v0.5.2
github.com/sirupsen/logrus v1.9.4
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
)
require (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/sys v0.39.0 // indirect
)

View File

@@ -1,68 +1,30 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmelahman/tag v0.5.2 h1:g6A/aHehu5tkA31mPoDsXBNr1FigZ9A82Y8WVgb/WsM=
github.com/jmelahman/tag v0.5.2/go.mod h1:qmuqk19B1BKkpcg3kn7l/Eey+UqucLxgOWkteUGiG4Q=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,419 +0,0 @@
package tui
import (
"fmt"
"github.com/gdamore/tcell/v2"
)
// PickerGroup represents a labelled group of selectable items.
type PickerGroup struct {
Label string
Items []string
}
// entry is a single row in the picker (either a group header or an item).
type entry struct {
label string
isHeader bool
selected bool
groupIdx int
flatIdx int // index across all items (ignoring headers), -1 for headers
}
// Pick shows a full-screen grouped multi-select picker.
// All items start deselected. Returns the flat indices of selected items
// (0-based, spanning all groups in order). Returns nil if cancelled.
// Returns a non-nil error if the terminal cannot be initialised, in which
// case the caller should fall back to a simpler prompt.
func Pick(groups []PickerGroup) ([]int, error) {
screen, err := tcell.NewScreen()
if err != nil {
return nil, err
}
if err := screen.Init(); err != nil {
return nil, err
}
defer screen.Fini()
entries := buildEntries(groups)
totalItems := countItems(entries)
cursor := firstSelectableIndex(entries)
offset := 0
for {
w, h := screen.Size()
selectedCount := countSelected(entries)
drawPicker(screen, entries, groups, cursor, offset, w, h, selectedCount, totalItems)
screen.Show()
ev := screen.PollEvent()
switch ev := ev.(type) {
case *tcell.EventResize:
screen.Sync()
case *tcell.EventKey:
switch action := keyAction(ev); action {
case actionQuit:
return nil, nil
case actionConfirm:
if countSelected(entries) > 0 {
return collectSelected(entries), nil
}
case actionUp:
if cursor > 0 {
cursor--
}
case actionDown:
if cursor < len(entries)-1 {
cursor++
}
case actionTop:
cursor = 0
case actionBottom:
if len(entries) == 0 {
cursor = 0
} else {
cursor = len(entries) - 1
}
case actionPageUp:
listHeight := h - headerLines - footerLines
cursor -= listHeight
if cursor < 0 {
cursor = 0
}
case actionPageDown:
listHeight := h - headerLines - footerLines
cursor += listHeight
if cursor >= len(entries) {
cursor = len(entries) - 1
}
case actionToggle:
toggleAtCursor(entries, cursor)
case actionAll:
setAll(entries, true)
case actionNone:
setAll(entries, false)
}
// Keep the cursor visible
listHeight := h - headerLines - footerLines
if listHeight < 1 {
listHeight = 1
}
if cursor < offset {
offset = cursor
}
if cursor >= offset+listHeight {
offset = cursor - listHeight + 1
}
}
}
}
// --- actions ----------------------------------------------------------------
type action int
const (
actionNoop action = iota
actionQuit
actionConfirm
actionUp
actionDown
actionTop
actionBottom
actionPageUp
actionPageDown
actionToggle
actionAll
actionNone
)
func keyAction(ev *tcell.EventKey) action {
switch ev.Key() {
case tcell.KeyEscape, tcell.KeyCtrlC:
return actionQuit
case tcell.KeyEnter:
return actionConfirm
case tcell.KeyUp:
return actionUp
case tcell.KeyDown:
return actionDown
case tcell.KeyHome:
return actionTop
case tcell.KeyEnd:
return actionBottom
case tcell.KeyPgUp:
return actionPageUp
case tcell.KeyPgDn:
return actionPageDown
case tcell.KeyRune:
switch ev.Rune() {
case 'q':
return actionQuit
case ' ':
return actionToggle
case 'j':
return actionDown
case 'k':
return actionUp
case 'g':
return actionTop
case 'G':
return actionBottom
case 'a':
return actionAll
case 'n':
return actionNone
}
}
return actionNoop
}
// --- data helpers ------------------------------------------------------------
func buildEntries(groups []PickerGroup) []entry {
var entries []entry
flat := 0
for gi, g := range groups {
entries = append(entries, entry{
label: g.Label,
isHeader: true,
groupIdx: gi,
flatIdx: -1,
})
for _, item := range g.Items {
entries = append(entries, entry{
label: item,
isHeader: false,
selected: false,
groupIdx: gi,
flatIdx: flat,
})
flat++
}
}
return entries
}
func firstSelectableIndex(entries []entry) int {
for i, e := range entries {
if !e.isHeader {
return i
}
}
return 0
}
func countItems(entries []entry) int {
n := 0
for _, e := range entries {
if !e.isHeader {
n++
}
}
return n
}
func countSelected(entries []entry) int {
n := 0
for _, e := range entries {
if !e.isHeader && e.selected {
n++
}
}
return n
}
func collectSelected(entries []entry) []int {
var result []int
for _, e := range entries {
if !e.isHeader && e.selected {
result = append(result, e.flatIdx)
}
}
return result
}
func toggleAtCursor(entries []entry, cursor int) {
if cursor < 0 || cursor >= len(entries) {
return
}
e := entries[cursor]
if e.isHeader {
// Toggle entire group: if all selected -> deselect all, else select all
allSelected := true
for _, e2 := range entries {
if !e2.isHeader && e2.groupIdx == e.groupIdx && !e2.selected {
allSelected = false
break
}
}
for i := range entries {
if !entries[i].isHeader && entries[i].groupIdx == e.groupIdx {
entries[i].selected = !allSelected
}
}
} else {
entries[cursor].selected = !entries[cursor].selected
}
}
func setAll(entries []entry, selected bool) {
for i := range entries {
if !entries[i].isHeader {
entries[i].selected = selected
}
}
}
// --- drawing ----------------------------------------------------------------
const (
headerLines = 2 // title + blank line
footerLines = 2 // blank line + keybinds
)
var (
styleDefault = tcell.StyleDefault
styleTitle = tcell.StyleDefault.Bold(true)
styleGroup = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorTeal)
styleGroupCur = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorTeal).Reverse(true)
styleCheck = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
styleUncheck = tcell.StyleDefault.Dim(true)
styleItem = tcell.StyleDefault
styleItemCur = tcell.StyleDefault.Bold(true).Underline(true)
styleCheckCur = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true).Underline(true)
styleUncheckCur = tcell.StyleDefault.Dim(true).Underline(true)
styleFooter = tcell.StyleDefault.Dim(true)
)
func drawPicker(
screen tcell.Screen,
entries []entry,
groups []PickerGroup,
cursor, offset, w, h, selectedCount, totalItems int,
) {
screen.Clear()
// Title
title := fmt.Sprintf(" Select traces to open (%d/%d selected)", selectedCount, totalItems)
drawLine(screen, 0, 0, w, title, styleTitle)
// List area
listHeight := h - headerLines - footerLines
if listHeight < 1 {
listHeight = 1
}
for i := 0; i < listHeight; i++ {
ei := offset + i
if ei >= len(entries) {
break
}
y := headerLines + i
renderEntry(screen, entries, groups, ei, cursor, w, y)
}
// Scrollbar hint
if len(entries) > listHeight {
drawScrollbar(screen, w-1, headerLines, listHeight, offset, len(entries))
}
// Footer
footerY := h - 1
footer := " ↑/↓ move space toggle a all n none enter open q/esc quit"
drawLine(screen, 0, footerY, w, footer, styleFooter)
}
func renderEntry(screen tcell.Screen, entries []entry, groups []PickerGroup, ei, cursor, w, y int) {
e := entries[ei]
isCursor := ei == cursor
if e.isHeader {
groupSelected := 0
groupTotal := 0
for _, e2 := range entries {
if !e2.isHeader && e2.groupIdx == e.groupIdx {
groupTotal++
if e2.selected {
groupSelected++
}
}
}
label := fmt.Sprintf(" %s (%d/%d)", e.label, groupSelected, groupTotal)
style := styleGroup
if isCursor {
style = styleGroupCur
}
drawLine(screen, 0, y, w, label, style)
return
}
// Item row: " [x] label" or " > [x] label"
prefix := " "
if isCursor {
prefix = " > "
}
check := "[ ]"
cStyle := styleUncheck
iStyle := styleItem
if isCursor {
cStyle = styleUncheckCur
iStyle = styleItemCur
}
if e.selected {
check = "[x]"
cStyle = styleCheck
if isCursor {
cStyle = styleCheckCur
}
}
x := drawStr(screen, 0, y, w, prefix, iStyle)
x = drawStr(screen, x, y, w, check, cStyle)
drawStr(screen, x, y, w, " "+e.label, iStyle)
}
func drawScrollbar(screen tcell.Screen, x, top, height, offset, total int) {
if total <= height || height < 1 {
return
}
thumbSize := max(1, height*height/total)
thumbPos := top + offset*height/total
for y := top; y < top+height; y++ {
ch := '│'
style := styleDefault.Dim(true)
if y >= thumbPos && y < thumbPos+thumbSize {
ch = '┃'
style = styleDefault
}
screen.SetContent(x, y, ch, nil, style)
}
}
// drawLine fills an entire row starting at x=startX, padding to width w.
func drawLine(screen tcell.Screen, startX, y, w int, s string, style tcell.Style) {
x := drawStr(screen, startX, y, w, s, style)
// Clear the rest of the line
for ; x < w; x++ {
screen.SetContent(x, y, ' ', nil, style)
}
}
// drawStr writes a string at (x, y) up to maxX and returns the next x position.
func drawStr(screen tcell.Screen, x, y, maxX int, s string, style tcell.Style) int {
for _, ch := range s {
if x >= maxX {
break
}
screen.SetContent(x, y, ch, nil, style)
x++
}
return x
}

4
web/package-lock.json generated
View File

@@ -10701,7 +10701,9 @@
"license": "MIT"
},
"node_modules/handlebars": {
"version": "4.7.8",
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"dev": true,
"license": "MIT",
"dependencies": {