diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 434e0524..094c5aad 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -15,11 +15,11 @@ # rethought (change-detection inside an always-running job, or a paths-ignore # mirror that reports success). # -# Deliberately NO `vitest` step: there are zero *.test.* / *.spec.* files -# under web/frontend/src, and `vitest run` on an empty suite exits 1 -# ("No test files found"), which would fail every frontend PR. When the first -# test lands, add `npm test` here -- vitest's fail-on-empty default then -# guards against the suite silently vanishing. +# The `vitest` step arrived with the first test file (`src/router.test.ts`, +# the hash-router parse/build contract). It was deliberately absent before +# that: `vitest run` on an empty suite exits 1 ("No test files found"), which +# would have failed every frontend PR. That same fail-on-empty default now +# works FOR us -- it guards against the suite silently vanishing. # ────────────────────────────────────────────────────────────────────────── name: Frontend @@ -37,7 +37,7 @@ on: jobs: frontend: - name: Type check + build (web/frontend) + name: Type check + test + build (web/frontend) runs-on: ubuntu-latest defaults: run: @@ -62,6 +62,11 @@ jobs: # annotation instead of burying it inside the build step. run: npx tsc --noEmit + - name: Test + # `vitest run` -- exits 1 on an empty suite, so this also fails if the + # test files are ever removed. + run: npm test + - name: Build # The same command hatch_build.py runs when bundling the SPA into the # wheel -- if this fails, `uv tool install` from git fails too. diff --git a/CLAUDE.md b/CLAUDE.md index 6b1eaa8f..bdb2ebae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -718,6 +718,16 @@ kbagent data-app git-credentials-create --project NAME --app-id ID --type ssh_ke kbagent component list [--project NAME] [--type TYPE] [--query QUERY] kbagent component detail --component-id ID [--project NAME] +# component detail (since vNEXT): the AI Service indexes the PUBLIC catalog only, so a private/ +# deprecated component the project can run (keboola.mcp-server-tool, keboola.data-apps) 404'd +# there while `component list` showed it -- over `serve` as an HTTP 502. A NOT_FOUND now falls +# back to the project's Storage component catalog; `documentation_source` ("ai_service" vs +# "storage_catalog") tells the two apart and is present on BOTH paths. The fallback has NO +# configuration examples (examples_count/row_examples_count always 0, schema_summary counts 0 +# unless the catalog entry ships a configurationSchema) -- check documentation_source before +# reading 0 as "this component has none". NOT_FOUND still raised when both sources miss; a +# non-404 AI Service failure is never masked. Over `serve`, ErrorCode.NOT_FOUND now maps to +# HTTP 404 (was 502) on EVERY route -- branch on error.code, not on the status alone. kbagent component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N] # sync-action (0.73.0+): POST sync-actions.{stack}/actions; ACTION_NAME freeform (component-defined, # e.g. testConnection/getTables); --row-id shallow-merges row over root at TOP level only (row diff --git a/docs/web-server.md b/docs/web-server.md index 1d3a6ae5..a01125db 100644 --- a/docs/web-server.md +++ b/docs/web-server.md @@ -98,6 +98,16 @@ else lives here, with their own agents that know their projects. Auto-generated OpenAPI spec at `/openapi.json`, Swagger UI at `/docs`. +An upstream Keboola failure surfaces through one global handler: a +`NOT_FOUND` answers **404** (since vNEXT — it used to be 502, which told +callers to retry a request that can never succeed), an expired/missing browser +session answers **401**, and every other `KeboolaApiError` answers **502**. The +body is always the `{"status": "error", "error": {"code", "message"}}` +envelope, so the `error.code` — not the HTTP status alone — is what a client +should branch on. `GET /components/{id}` in particular no longer 404s for a +component the AI Service does not index: it falls back to the project's Storage +catalog and marks the response `documentation_source: "storage_catalog"`. + ### Streaming endpoints (Server-Sent Events) - `/jobs/{project}/{job_id}/stream` — live job status transitions + log tail. @@ -167,6 +177,27 @@ A NERD-themed React SPA that drives the API: Manage API token. The UI prompts for it per-action via a hidden modal, forwards as `X-Manage-Token` for that one request, never persists. +**Shareable deep links.** Every view is addressable, so a URL copied out +of the address bar reopens exactly what the sender was looking at. The +whole navigation state lives in the location hash: `#/` for a page +with no project context, `#/p//` once a project is +selected, `?branch=` for a dev branch, and `?sel=` for the +page's selected object — a job id on Jobs, `/` on +Configs, and so on. The hash rather than a path, because the SPA is +mounted at the **root** of the same FastAPI app that serves the REST API: +a history-mode `/projects` would collide with the endpoint that returns +JSON, while everything after `#` is never sent to the server at all. +`sel` is opaque to the router — the page that writes it defines its +shape, and it is dropped on any page / project / branch change, since an +object id from one context means nothing in the next. A link whose object +no longer resolves in the current project simply opens the page with no +drawer. + +Detail drawers render an **Overview** tab (the payload's fields as +labelled sections, with pills for status and nested blobs kept verbatim) +and keep the untouched response one click away under **Raw JSON** with a +copy button, so nothing the API returned is ever hidden. + ## Architecture Three processes, three languages, one HTTP/JSON contract between each diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 3fe545b0..c2373df2 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -119,7 +119,7 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def - `component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N]` (since 0.73.0) -- run a synchronous component action (`testConnection`, `getTables`, ...) on the `sync-actions.{stack}` service. `ACTION_NAME` is freeform (component-defined; discover via `component detail` `synchronous_actions`). `--row-id` shallow-merges the row over the root config at TOP level only (row `parameters`/`storage` replace root wholesale -- NOT deep merge; MCP `run_sync_action` parity). `--config-data` sends explicit `configData` verbatim. Response is action-specific pass-through. Ports the `run_sync_action` MCP tool. **Since 0.89.0 (#620)** the ROOT configuration's `authorization` and `runtime` blocks are forwarded into `configData` too -- root only (a `--row-id` never overrides them), and only when non-empty. `authorization.oauth_api.id` is the OAuth broker reference the sync-actions service resolves and decrypts, so on 0.88.0 and earlier every sync action on an OAuth / Service-Account component (`keboola.ex-linkedin-ads`, ...) failed with an opaque empty-body 400. - `config examples --component-id ID [--project NAME] [--row]` (since 0.73.0) -- sample root/row configurations from the AI-service component detail. `--json` emits `{component_id, root_examples, row_examples}`; `--row` limits to row examples. Ports the `get_config_examples` MCP tool. - `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`) -- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples +- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples. **Since vNEXT** a component the AI Service does not index (private/deprecated: `keboola.mcp-server-tool`, `keboola.data-apps`) no longer errors -- it falls back to the project's Storage component catalog. `documentation_source` (`"ai_service"` vs `"storage_catalog"`) is on BOTH paths and tells them apart; the fallback has NO configuration examples (`examples_count`/`row_examples_count` always 0), so read `documentation_source` before treating 0 as "this component ships none". `NOT_FOUND` is still raised when both sources miss. See `gotchas.md`. ## Configuration Browsing - `config list [--project NAME] [--component-type TYPE] [--component-id ID] [--branch ID] [--include-rows]` -- list configs across projects (branch-aware). With `--include-rows` each row extends to include the full `configuration` and `rows` body (noticeably larger payload -- use only when the bodies are needed; the summary default covers name/description/component/last_modified/folder) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 03e1dd49..ea8dd975 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -4384,3 +4384,31 @@ documented multi-branch pull was enough (issue #649). manifest to the branch you are actually on. `--json` callers should treat a non-zero `summary.orphaned` as "the manifest is pointing at another branch", not as a per-config problem. + +## `component detail` falls back to the Storage catalog for un-indexed components (since vNEXT) + +`component detail` reads the AI Service (`/docs/components/{id}`), which indexes +the **public** component catalog only. A private or deprecated component the +project can actually run -- `keboola.mcp-server-tool`, `keboola.data-apps` -- +is listed by `component list` (Storage API) yet missing from that index, so the +command used to fail with `NOT_FOUND` for exactly the components an operator is +least likely to know by heart. Over `kbagent serve` it was worse: the global +handler mapped it to **HTTP 502**, so `GET /components/keboola.mcp-server-tool` +looked like an upstream outage worth retrying. + +- **An AI Service `NOT_FOUND` now falls back to the project's Storage component + catalog** and returns the same response shape filled from the catalog entry. +- **`documentation_source` is the discriminator**: `"ai_service"` (full detail) + vs `"storage_catalog"` (fallback). It is present on BOTH paths, so a `--json` + consumer can branch on it without a version check once it is on vNEXT+. +- **The fallback carries no configuration examples.** `examples_count` / + `row_examples_count` are always `0` there, and `schema_summary` counts are `0` + unless the catalog entry itself ships a `configurationSchema`. Read + `documentation_source` before concluding "this component has no examples" -- + use `config examples` / the component's own docs instead. +- **A NOT_FOUND is still raised when both sources miss** -- that is the case + where the component id really is wrong. Any non-404 AI Service failure (auth, + network, 5xx) is re-raised as itself and never masked by a catalog hit. +- **Over `serve`, a `KeboolaApiError` with code `NOT_FOUND` now answers HTTP + 404, not 502** (all routers, not just components). Branch on + `error.code`, not on the HTTP status alone. diff --git a/src/keboola_agent_cli/commands/component.py b/src/keboola_agent_cli/commands/component.py index 7a0d5dab..20582482 100644 --- a/src/keboola_agent_cli/commands/component.py +++ b/src/keboola_agent_cli/commands/component.py @@ -15,6 +15,7 @@ from ..config_store import ConfigStore from ..constants import VALID_COMPONENT_TYPES from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..services.component_service import DOCUMENTATION_SOURCE_STORAGE_CATALOG from ._helpers import ( check_cli_permission, emit_project_warnings, @@ -130,6 +131,16 @@ def _format_component_detail(console: Console, data: dict) -> None: if examples_count: lines.append(f"[bold]Examples:[/bold] {examples_count} root config example(s)") + # Say so when the AI Service did not index this component: otherwise the + # missing schema/examples read as "this component has none" rather than + # "this view cannot show them". + if data.get("documentation_source") == DOCUMENTATION_SOURCE_STORAGE_CATALOG: + lines.append( + "\n[yellow]Source:[/yellow] project Storage catalog -- the Keboola AI Service " + "has no documentation indexed for this component, so its configuration schema " + "and examples are unavailable." + ) + panel = Panel("\n".join(lines), title=f"Component - {name}", expand=False) console.print(panel) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index d257c3c7..ad78934d 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -341,6 +341,15 @@ kbagent component detail --component-id ID [--project NAME] Show component docs, config schema, and examples count. + (since vNEXT) The AI Service indexes the PUBLIC catalog only, so a private + or deprecated component the project can run (keboola.mcp-server-tool, + keboola.data-apps) used to 404 here while `component list` showed it. A + NOT_FOUND now falls back to the project's Storage component catalog. + documentation_source ("ai_service" vs "storage_catalog") is present on BOTH + paths and tells them apart; the fallback carries NO configuration examples + (examples_count / row_examples_count always 0), so check + documentation_source before reading 0 as "this component ships none". + NOT_FOUND is still raised when both sources miss. kbagent component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N] (since 0.73.0) Run a synchronous component action (testConnection, getTables, diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index f18ebe29..f6ff0778 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -485,9 +485,11 @@ def _format_error( # A browser-login session backing a session-registered project is USER-scoped # and lives on the host, so its failures are the caller's authentication -# problem rather than an upstream fault: they answer 401, not the 502 every -# other `KeboolaApiError` maps to. The server cannot renew such a session -# itself -- a browser login only completes where a human sits. +# problem rather than an upstream fault: they answer 401, not the 502 a +# `KeboolaApiError` maps to by default (NOT_FOUND is the other exception -- +# it answers 404; an upstream "no such resource" is not a Bad Gateway). The +# server cannot renew such a session itself -- a browser login only completes +# where a human sits. _SESSION_CREDENTIAL_CODES = frozenset({ErrorCode.SESSION_EXPIRED, ErrorCode.SESSION_NOT_FOUND}) _SESSION_REMEDY_ON_HOST = ( @@ -666,6 +668,11 @@ async def _api_error_handler(_request, exc: KeboolaApiError): msg = getattr(exc, "message", str(exc)) or str(exc) if code in _SESSION_CREDENTIAL_CODES: return _format_error(f"{msg} {_SESSION_REMEDY_ON_HOST}", code, http_status=401) + if code == ErrorCode.NOT_FOUND: + # An upstream 404 is a statement about the requested resource, not + # about the gateway: reporting it as 502 made callers retry (and + # page on-call for) a request that can never succeed. + return _format_error(msg, code, http_status=404) return _format_error(msg, code, http_status=502) @app.exception_handler(StarletteHTTPException) diff --git a/src/keboola_agent_cli/services/component_service.py b/src/keboola_agent_cli/services/component_service.py index d0a5d7bb..0d186141 100644 --- a/src/keboola_agent_cli/services/component_service.py +++ b/src/keboola_agent_cli/services/component_service.py @@ -15,7 +15,7 @@ from ..ai_client import AiServiceClient from ..config_store import ConfigStore from ..constants import CONFIG_FILENAME, SECRET_PLACEHOLDER -from ..errors import ConfigError, KeboolaApiError +from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..models import ComponentDetail, ComponentSuggestion, ProjectConfig from ..sync.code_extraction import DESCRIPTION_FILENAME, extract_code_files from ..sync.config_format import api_config_to_local, dump_config_yaml @@ -461,6 +461,17 @@ def _build_flow_config_yml(name: str, component_id: str = "keboola.flow") -> str return "\n".join(lines) + "\n" +# Where a `component detail` response came from. The AI Service indexes the +# PUBLIC component catalog only, so a component a project can actually run but +# that the index does not carry (private/deprecated ones such as +# ``keboola.mcp-server-tool`` or ``keboola.data-apps``) 404s there while still +# being listed by ``component list``. The Storage catalog is the fallback; it +# carries no configuration examples, so consumers need to tell the two apart +# instead of reading an empty ``examples_count`` as "this component has none". +DOCUMENTATION_SOURCE_AI_SERVICE = "ai_service" +DOCUMENTATION_SOURCE_STORAGE_CATALOG = "storage_catalog" + + class ComponentService(BaseService): """Business logic for component discovery and scaffold generation. @@ -512,29 +523,63 @@ def list_components( return self._list_via_storage(aliases, component_type) def get_component_detail(self, alias: str, component_id: str) -> dict[str, Any]: - """Fetch detailed component documentation via AI Service. + """Fetch detailed component documentation, AI Service first. + + The AI Service (``/docs/components/{id}``) indexes the PUBLIC component + catalog only, so its 404 is NOT proof the component does not exist: a + private or deprecated component the project can actually run -- + ``keboola.mcp-server-tool``, ``keboola.data-apps`` -- is listed by + ``component list`` (Storage API) yet missing from the index. Erroring + out there made ``component detail`` unusable for exactly the components + an operator is least likely to know by heart. + + A NOT_FOUND therefore falls back to the project's own Storage component + catalog and returns the same response shape filled from the catalog + entry. Fields only the AI Service has come back empty rather than + absent (``examples_count`` / ``row_examples_count`` are always 0, and + ``schema_summary`` counts stay at 0 unless the catalog entry itself + carries a configuration schema), so no consumer has to branch on the + source to read a field. ``documentation_source`` is what tells the two + apart: ``"ai_service"`` vs ``"storage_catalog"``. + + Any other AI Service failure (auth, network, 5xx) is re-raised as + itself -- only the ambiguous 404 is worth a second lookup. Args: alias: Project alias (used to derive stack URL and token). component_id: The component identifier (e.g. 'keboola.ex-aws-s3'). Returns: - Dict with component detail including schema summary, - examples count, and full documentation. + Dict with component detail including schema summary, example + counts, ``documentation_source``, and full documentation. Raises: ConfigError: If the alias is not found. - KeboolaApiError: If the AI Service call fails. + KeboolaApiError: If the AI Service call fails; a NOT_FOUND is + re-raised unchanged only when the Storage catalog does not know + the component either (i.e. the id really is wrong). """ projects = self.resolve_projects([alias]) project = projects[alias] ai_client = self._ai_client_factory(project.stack_url, project.token) + not_found: KeboolaApiError | None = None + raw: dict[str, Any] = {} try: raw = ai_client.get_component_detail(component_id) + except KeboolaApiError as exc: + if exc.error_code != ErrorCode.NOT_FOUND: + raise + not_found = exc finally: ai_client.close() + if not_found is not None: + catalog_entry = self._find_catalog_component(project, component_id) + if catalog_entry is None: + raise not_found + return self._catalog_detail_payload(catalog_entry, alias) + detail = ComponentDetail(**raw) # Build schema summary @@ -559,6 +604,61 @@ def get_component_detail(self, alias: str, component_id: str) -> dict[str, Any]: "examples_count": len(detail.root_configuration_examples), "row_examples_count": len(detail.row_configuration_examples), "project_alias": alias, + "documentation_source": DOCUMENTATION_SOURCE_AI_SERVICE, + } + + def _find_catalog_component( + self, project: ProjectConfig, component_id: str + ) -> dict[str, Any] | None: + """Return the project's Storage catalog entry for *component_id*, or None. + + Reads the same listing :meth:`_list_via_storage` uses + (``GET /v2/storage/components``) and matches on the exact id, so + anything ``component list`` shows stays inspectable through + ``component detail``. + """ + client = self._client_factory(project.stack_url, project.token) + try: + for entry in client.list_components(): + if entry.get("id") == component_id: + return entry + finally: + client.close() + return None + + @staticmethod + def _catalog_detail_payload(entry: dict[str, Any], alias: str) -> dict[str, Any]: + """Shape a Storage catalog entry like an AI Service detail response. + + Every key the AI path returns is present. ``or``-defaults rather than + ``dict.get`` defaults throughout: the Storage API sends explicit + ``null`` for an absent description / documentation URL, and a ``None`` + would break the human formatter's string rendering. + """ + schema = entry.get("configurationSchema") or {} + properties = schema.get("properties") or {} + required = schema.get("required") or [] + return { + "component_id": entry.get("id") or "", + "component_name": entry.get("name") or "", + "component_type": entry.get("type") or "", + "categories": entry.get("categories") or [], + "flags": entry.get("flags") or [], + "description": entry.get("description") or "", + "long_description": entry.get("longDescription") or "", + "documentation_url": entry.get("documentationUrl") or "", + "schema_summary": { + "property_count": len(properties), + "required_count": len(required), + "has_row_schema": bool(entry.get("configurationRowSchema")), + }, + # The Storage catalog has no configuration examples at all; a + # consumer that needs them must read `documentation_source` + # instead of concluding the component ships none. + "examples_count": 0, + "row_examples_count": 0, + "project_alias": alias, + "documentation_source": DOCUMENTATION_SOURCE_STORAGE_CATALOG, } def get_config_examples(self, alias: str | None, component_id: str) -> dict[str, Any]: diff --git a/tests/test_component_service.py b/tests/test_component_service.py index 704b9fb3..6882e350 100644 --- a/tests/test_component_service.py +++ b/tests/test_component_service.py @@ -9,8 +9,10 @@ from helpers import setup_single_project from keboola_agent_cli.constants import SECRET_PLACEHOLDER -from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.errors import ErrorCode, KeboolaApiError from keboola_agent_cli.services.component_service import ( + DOCUMENTATION_SOURCE_AI_SERVICE, + DOCUMENTATION_SOURCE_STORAGE_CATALOG, ComponentService, _detect_component_category, _generate_from_schema, @@ -156,6 +158,27 @@ "rowConfigurationExamples": [], } +# A Storage API `/v2/storage/components` entry (snake-less API shape: `id` / +# `name` / `type`, not the AI Service's `componentId` / ...). This is the +# private component the AI Service index does NOT carry, so `component detail` +# has to fall back to it. +CATALOG_ONLY_COMPONENT: dict[str, Any] = { + "id": "keboola.mcp-server-tool", + "name": "MCP Server Tool", + "type": "application", + "categories": ["AI"], + "flags": ["excludeFromNewList"], + "description": "Internal MCP tool runner", + "longDescription": "Runs MCP server tools.", + "documentationUrl": "https://help.keboola.com/mcp/", + "configurationSchema": { + "type": "object", + "required": ["tool"], + "properties": {"tool": {"type": "string"}}, + }, + "configurationRowSchema": {}, +} + EMPTY_SCHEMA_RESPONSE: dict[str, Any] = { "componentId": "keboola.ex-empty", "componentName": "Empty", @@ -345,27 +368,155 @@ def test_get_component_detail_success(self, tmp_config_dir: Path) -> None: assert result["examples_count"] == 1 assert result["row_examples_count"] == 0 + assert result["documentation_source"] == DOCUMENTATION_SOURCE_AI_SERVICE mock_ai.get_component_detail.assert_called_once_with("keboola.ex-http") mock_ai.close.assert_called_once() def test_get_component_detail_not_found(self, tmp_config_dir: Path) -> None: - """Raises KeboolaApiError when AI service returns 404.""" + """Re-raises the AI Service 404 when the Storage catalog misses too. + + Both sources agreeing is the only case where the id really is wrong, + so the original NOT_FOUND has to survive the fallback unchanged. + """ mock_ai = MagicMock() mock_ai.get_component_detail.side_effect = KeboolaApiError( message="Component not found", status_code=404, - error_code="NOT_FOUND", + error_code=ErrorCode.NOT_FOUND, retryable=False, ) - service = _make_service(tmp_config_dir, ai_client=mock_ai) + mock_storage = _make_storage_client([]) + service = _make_service(tmp_config_dir, ai_client=mock_ai, storage_client=mock_storage) with pytest.raises(KeboolaApiError) as exc_info: service.get_component_detail(alias="prod", component_id="nonexistent.component") - assert exc_info.value.error_code == "NOT_FOUND" + assert exc_info.value.error_code == ErrorCode.NOT_FOUND assert exc_info.value.status_code == 404 mock_ai.close.assert_called_once() + mock_storage.close.assert_called_once() + + def test_get_component_detail_falls_back_to_storage_catalog(self, tmp_config_dir: Path) -> None: + """A component the AI Service does not index is served from the catalog. + + Private/deprecated components (keboola.mcp-server-tool, + keboola.data-apps) are listed by `component list` but 404 in the AI + Service index -- before the fallback, `component detail` was unusable + for them (and `serve` answered 502). + """ + mock_ai = MagicMock() + mock_ai.get_component_detail.side_effect = KeboolaApiError( + message='Component "keboola.mcp-server-tool" not found', + status_code=404, + error_code=ErrorCode.NOT_FOUND, + retryable=False, + ) + mock_storage = _make_storage_client([CATALOG_ONLY_COMPONENT]) + service = _make_service(tmp_config_dir, ai_client=mock_ai, storage_client=mock_storage) + + result = service.get_component_detail(alias="prod", component_id="keboola.mcp-server-tool") + + assert result["documentation_source"] == DOCUMENTATION_SOURCE_STORAGE_CATALOG + assert result["component_id"] == "keboola.mcp-server-tool" + assert result["component_name"] == "MCP Server Tool" + assert result["component_type"] == "application" + assert result["categories"] == ["AI"] + assert result["flags"] == ["excludeFromNewList"] + assert result["description"] == "Internal MCP tool runner" + assert result["long_description"] == "Runs MCP server tools." + assert result["documentation_url"] == "https://help.keboola.com/mcp/" + assert result["project_alias"] == "prod" + # Schema counts come from the catalog entry when it carries a schema. + assert result["schema_summary"] == { + "property_count": 1, + "required_count": 1, + "has_row_schema": False, + } + # The Storage catalog has no configuration examples at all. + assert result["examples_count"] == 0 + assert result["row_examples_count"] == 0 + + mock_ai.close.assert_called_once() + mock_storage.close.assert_called_once() + + def test_fallback_response_has_same_keys_as_ai_response(self, tmp_config_dir: Path) -> None: + """Both sources return the same key set, so consumers never branch to read a field.""" + ai_only = _make_service( + tmp_config_dir, ai_client=_make_ai_client(detail_response=EXTRACTOR_RESPONSE) + ) + ai_result = ai_only.get_component_detail(alias="prod", component_id="keboola.ex-http") + + mock_ai = MagicMock() + mock_ai.get_component_detail.side_effect = KeboolaApiError( + message="not found", status_code=404, error_code=ErrorCode.NOT_FOUND + ) + # A second alias, because setup_single_project() refuses to re-add one. + fallback_service = _make_service( + tmp_config_dir, + ai_client=mock_ai, + storage_client=_make_storage_client([CATALOG_ONLY_COMPONENT]), + alias="catalog-proj", + ) + fallback_result = fallback_service.get_component_detail( + alias="catalog-proj", component_id="keboola.mcp-server-tool" + ) + + assert set(fallback_result) == set(ai_result) + + def test_catalog_entry_with_null_fields_renders_as_empty_strings( + self, tmp_config_dir: Path + ) -> None: + """Storage sends explicit null for absent docs; the payload must not leak None.""" + mock_ai = MagicMock() + mock_ai.get_component_detail.side_effect = KeboolaApiError( + message="not found", status_code=404, error_code=ErrorCode.NOT_FOUND + ) + sparse_entry = { + "id": "keboola.data-apps", + "name": "Data Apps", + "type": "application", + "description": None, + "longDescription": None, + "documentationUrl": None, + "categories": None, + "flags": None, + } + service = _make_service( + tmp_config_dir, ai_client=mock_ai, storage_client=_make_storage_client([sparse_entry]) + ) + + result = service.get_component_detail(alias="prod", component_id="keboola.data-apps") + + assert result["description"] == "" + assert result["long_description"] == "" + assert result["documentation_url"] == "" + assert result["categories"] == [] + assert result["flags"] == [] + assert result["schema_summary"] == { + "property_count": 0, + "required_count": 0, + "has_row_schema": False, + } + + def test_non_not_found_ai_error_is_not_masked_by_fallback(self, tmp_config_dir: Path) -> None: + """An auth/network failure must surface as itself, never as a catalog hit.""" + mock_ai = MagicMock() + mock_ai.get_component_detail.side_effect = KeboolaApiError( + message="Invalid or expired token", + status_code=401, + error_code=ErrorCode.INVALID_TOKEN, + retryable=False, + ) + mock_storage = _make_storage_client([CATALOG_ONLY_COMPONENT]) + service = _make_service(tmp_config_dir, ai_client=mock_ai, storage_client=mock_storage) + + with pytest.raises(KeboolaApiError) as exc_info: + service.get_component_detail(alias="prod", component_id="keboola.mcp-server-tool") + + assert exc_info.value.error_code == ErrorCode.INVALID_TOKEN + mock_storage.list_components.assert_not_called() + mock_ai.close.assert_called_once() # =========================================================================== diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index ac282e57..1b0c6e76 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -1416,8 +1416,8 @@ def test_config_examples_project_optional(tmp_path: Path) -> None: assert component_svc.get_config_examples.call_args.kwargs["alias"] is None -def test_config_examples_api_error_is_502(tmp_path: Path) -> None: - """AI Service failure (KeboolaApiError) -> HTTP 502 error envelope.""" +def test_config_examples_not_found_is_404(tmp_path: Path) -> None: + """An upstream NOT_FOUND is about the resource, so it answers 404, not 502.""" from keboola_agent_cli.errors import ErrorCode, KeboolaApiError component_svc = MagicMock() @@ -1430,10 +1430,84 @@ def test_config_examples_api_error_is_502(tmp_path: Path) -> None: with TestClient(app) as client: res = client.get(f"/configs/examples/{COMPONENT}", headers=AUTH) - assert res.status_code == 502, res.text + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND" assert "Component not found" in res.json()["error"]["message"] +def test_config_examples_api_error_is_502(tmp_path: Path) -> None: + """A genuine upstream fault (non-NOT_FOUND KeboolaApiError) keeps its 502.""" + from keboola_agent_cli.errors import ErrorCode, KeboolaApiError + + component_svc = MagicMock() + component_svc.get_config_examples.side_effect = KeboolaApiError( + message="AI Service is unavailable", status_code=503, error_code=ErrorCode.API_ERROR + ) + registry = _mock_registry(component=component_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get(f"/configs/examples/{COMPONENT}", headers=AUTH) + + assert res.status_code == 502, res.text + assert res.json()["error"]["code"] == "API_ERROR" + + +# --------------------------------------------------------------------------- +# components.py GET /components/{component_id} +# Service: component.get_component_detail(alias=..., component_id=...) +# --------------------------------------------------------------------------- + + +def test_component_detail_not_found_is_404_not_502(tmp_path: Path) -> None: + """A component neither source knows answers 404 -- it is not a gateway fault. + + Regression: `GET /components/keboola.mcp-server-tool` used to answer 502 + with a NOT_FOUND body, so callers retried a request that can never succeed. + """ + from keboola_agent_cli.errors import ErrorCode, KeboolaApiError + + component_svc = MagicMock() + component_svc.get_component_detail.side_effect = KeboolaApiError( + message='Resource not found: Component "nope.nope" not found', + status_code=404, + error_code=ErrorCode.NOT_FOUND, + ) + registry = _mock_registry(component=component_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/components/nope.nope", params={"project": PROJECT}, headers=AUTH) + + assert res.status_code == 404, res.text + body = res.json() + assert body["status"] == "error" + assert body["error"]["code"] == "NOT_FOUND" + + +def test_component_detail_returns_storage_catalog_source(tmp_path: Path) -> None: + """The catalog fallback reaches the REST caller with its discriminator intact.""" + component_svc = MagicMock() + component_svc.get_component_detail.return_value = { + "component_id": "keboola.mcp-server-tool", + "component_name": "MCP Server Tool", + "documentation_source": "storage_catalog", + } + registry = _mock_registry(component=component_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get( + "/components/keboola.mcp-server-tool", params={"project": PROJECT}, headers=AUTH + ) + + assert res.status_code == 200, res.text + assert res.json()["documentation_source"] == "storage_catalog" + component_svc.get_component_detail.assert_called_once_with( + alias=PROJECT, component_id="keboola.mcp-server-tool" + ) + + # --------------------------------------------------------------------------- # components.py POST /components/{component_id}/actions/{action} # Service: component.run_sync_action(...) (mirrors `kbagent component sync-action`) @@ -1677,8 +1751,8 @@ def test_transformation_show_forwards_kwargs(tmp_path: Path) -> None: ) -def test_transformation_show_not_found_is_502(tmp_path: Path) -> None: - """Config not found under any SQL component -> KeboolaApiError -> HTTP 502.""" +def test_transformation_show_not_found_is_404(tmp_path: Path) -> None: + """Config not found under any SQL component -> KeboolaApiError(NOT_FOUND) -> HTTP 404.""" from keboola_agent_cli.errors import ErrorCode, KeboolaApiError tf = MagicMock() @@ -1692,7 +1766,8 @@ def test_transformation_show_not_found_is_502(tmp_path: Path) -> None: with TestClient(app) as client: res = client.get(f"/transformations/{PROJECT}/{CONFIG_ID}", headers=AUTH) - assert res.status_code == 502, res.text + assert res.status_code == 404, res.text + assert res.json()["error"]["code"] == "NOT_FOUND" assert "was not found" in res.json()["error"]["message"] diff --git a/tests/test_server_smoke.py b/tests/test_server_smoke.py index e6290116..ae48b0dd 100644 --- a/tests/test_server_smoke.py +++ b/tests/test_server_smoke.py @@ -427,6 +427,31 @@ def test_non_session_api_error_keeps_502(tmp_path: Path) -> None: assert "host running `kbagent serve`" not in res.json()["error"]["message"] +def test_not_found_api_error_maps_to_404(tmp_path: Path) -> None: + """A NOT_FOUND KeboolaApiError answers 404, not 502. + + An upstream "no such resource" is a statement about the request, not about + the gateway: 502 told callers to retry something that can never succeed. + """ + from keboola_agent_cli.errors import ErrorCode, KeboolaApiError + + exc = KeboolaApiError( + message="Resource not found: job 123 does not exist.", + status_code=404, + error_code=ErrorCode.NOT_FOUND, + retryable=False, + ) + with _client_with_failing_job_service(tmp_path, exc) as test_client: + res = test_client.get("/jobs", headers={"Authorization": "Bearer test-token"}) + + assert res.status_code == 404, res.text + body = res.json() + assert body["status"] == "error" + assert body["error"]["code"] == "NOT_FOUND" + # No session remedy is appended -- that branch stays exclusive to 401. + assert "host running `kbagent serve`" not in body["error"]["message"] + + def test_config_error_keeps_400(tmp_path: Path) -> None: """`ConfigError` keeps its own 400 mapping, unaffected by the 401 branch.""" from keboola_agent_cli.errors import ConfigError diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 9262e2a4..5a2d8f40 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.51.23", - "@tanstack/react-router": "^1.49.1", "clsx": "^2.1.1", "lucide-react": "^0.428.0", "mermaid": "^11.16.1", @@ -840,19 +839,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tanstack/history": { - "version": "1.161.6", - "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", - "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", - "license": "MIT", - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tanstack/query-core": { "version": "5.100.10", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", @@ -879,76 +865,6 @@ "react": "^18 || ^19" } }, - "node_modules/@tanstack/react-router": { - "version": "1.169.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.169.2.tgz", - "integrity": "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.161.6", - "@tanstack/react-store": "^0.9.3", - "@tanstack/router-core": "1.169.2", - "isbot": "^5.1.22" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=18.0.0 || >=19.0.0", - "react-dom": ">=18.0.0 || >=19.0.0" - } - }, - "node_modules/@tanstack/react-store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", - "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", - "license": "MIT", - "dependencies": { - "@tanstack/store": "0.9.3", - "use-sync-external-store": "^1.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/router-core": { - "version": "1.169.2", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.169.2.tgz", - "integrity": "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==", - "license": "MIT", - "dependencies": { - "@tanstack/history": "1.161.6", - "cookie-es": "^3.0.0", - "seroval": "^1.5.4", - "seroval-plugins": "^1.5.4" - }, - "engines": { - "node": ">=20.19" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", - "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1841,12 +1757,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", - "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", - "license": "MIT" - }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -2905,15 +2815,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isbot": { - "version": "5.1.40", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.40.tgz", - "integrity": "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==", - "license": "Unlicense", - "engines": { - "node": ">=18" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -4958,27 +4859,6 @@ "semver": "bin/semver.js" } }, - "node_modules/seroval": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", - "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/seroval-plugins": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", - "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "seroval": "^1.0" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5434,15 +5314,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/web/frontend/package.json b/web/frontend/package.json index 887578e0..58e7f393 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -12,7 +12,6 @@ "dependencies": { "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.51.23", - "@tanstack/react-router": "^1.49.1", "clsx": "^2.1.1", "lucide-react": "^0.428.0", "mermaid": "^11.16.1", diff --git a/web/frontend/src/components/DetailTabs.tsx b/web/frontend/src/components/DetailTabs.tsx new file mode 100644 index 00000000..4c132bb1 --- /dev/null +++ b/web/frontend/src/components/DetailTabs.tsx @@ -0,0 +1,41 @@ +export interface DetailTab { + id: string; + label: string; +} + +/** + * Tab bar for detail views (drawer / side panel bodies). + * + * The button styling is lifted verbatim from the hand-rolled tab rows on the + * Streams / Flows / Storage detail panels (`nerd-btn text-xs` + the + * `border-keboola text-keboola` active marker) so a converted page is visually + * indistinguishable from the ones still rolling their own. + */ +export function DetailTabs({ + tabs, + active, + onChange, + className = "flex flex-wrap gap-2 mb-4", +}: { + tabs: DetailTab[]; + active: string; + onChange: (id: string) => void; + className?: string; +}) { + return ( +
+ {tabs.map((t) => ( + + ))} +
+ ); +} diff --git a/web/frontend/src/components/Drawer.tsx b/web/frontend/src/components/Drawer.tsx index cf092378..f607723c 100644 --- a/web/frontend/src/components/Drawer.tsx +++ b/web/frontend/src/components/Drawer.tsx @@ -1,7 +1,18 @@ -import { X } from "lucide-react"; -import { type ReactNode, useEffect } from "react"; +import { Maximize2, Minimize2, X } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; import { createPortal } from "react-dom"; +/** Default panel width — unchanged from before ``wide`` existed. */ +const DEFAULT_WIDTH = "75vw"; +/** ``wide`` preset: roomier for JSON-heavy detail bodies. */ +const WIDE_WIDTH = "min(1400px, 90vw)"; +/** + * Expanded (header toggle) width. ``14rem`` is the Sidebar's ``w-56`` — the + * panel stops short of it so the nav stays visible and the user keeps their + * bearings instead of getting a full-screen modal. + */ +const EXPANDED_WIDTH = "calc(100vw - 14rem)"; + /** * Right-side slide-over drawer. Fixed-position, full viewport height, * blocks scroll behind it. Use for "open detail / runner without @@ -24,7 +35,11 @@ export function Drawer({ // the viewport (``vw`` units) and sidesteps a Tailwind JIT quirk where // arbitrary ``max-w-[…]`` values declared as default-parameter literals // can silently be dropped by the content scanner. - width = "75vw", + width, + // Shorthand for the roomier preset. Takes precedence over ``width`` so a + // legacy call site can be opted into the wider panel without hunting down + // its width literal. + wide = false, onClose, actions, children, @@ -33,10 +48,19 @@ export function Drawer({ title: string; subtitle?: string; width?: string; + wide?: boolean; onClose: () => void; actions?: ReactNode; children: ReactNode; }) { + // Expand/collapse is per-open state on purpose: nothing is persisted, so a + // drawer always opens at its declared size. + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (!open) setExpanded(false); + }, [open]); + useEffect(() => { if (!open) return; const onEsc = (e: KeyboardEvent) => { @@ -55,9 +79,14 @@ export function Drawer({ // utilities are detected by the ``max-w-`` prefix so legacy callers that // pass ``"max-w-3xl"`` keep working; everything else (``75vw``, ``50rem``, // ``800px``) flows through ``style.maxWidth``. - const isTailwindClass = width.startsWith("max-w-"); - const widthClass = isTailwindClass ? width : ""; - const widthStyle = isTailwindClass ? undefined : { maxWidth: width }; + const effectiveWidth = expanded + ? EXPANDED_WIDTH + : wide + ? WIDE_WIDTH + : (width ?? DEFAULT_WIDTH); + const isTailwindClass = effectiveWidth.startsWith("max-w-"); + const widthClass = isTailwindClass ? effectiveWidth : ""; + const widthStyle = isTailwindClass ? undefined : { maxWidth: effectiveWidth }; // Semi-transparent scrim. The earlier 90% opacity looked like the left half // of the screen had crashed (#286) — Vojta reported the page felt broken, // not modal. Dropping to 50% (light) / 70% (dark) + blur restores the "I @@ -80,6 +109,15 @@ export function Drawer({
{actions} + + {state === "manual" ? ( + + Clipboard unavailable (non-secure origin) — select the JSON below and copy manually. + + ) : null} +
+ ); +} diff --git a/web/frontend/src/layout/TopBar.tsx b/web/frontend/src/layout/TopBar.tsx index 5a5a2c89..92990f6e 100644 --- a/web/frontend/src/layout/TopBar.tsx +++ b/web/frontend/src/layout/TopBar.tsx @@ -26,14 +26,25 @@ export function TopBar() { enabled: !!project, }); - // Auto-select default project on load + // Auto-select the default project on load. + // + // A project pinned by the URL (`#/p//...`) WINS: the UI state is + // seeded from the hash before the first render, so `project` is already set + // by the time this effect runs and the early return leaves it alone. The one + // exception is an alias this installation does not know -- a link shared from + // someone else's machine, or a project since renamed/removed. Every page + // keys its queries off the alias, so keeping it would render nothing but + // errors; fall back to the default and drop the branch/selection that were + // scoped to it. useEffect(() => { - if (project) return; const projects = projectsQ.data?.projects; if (!projects?.length) return; + if (project && projects.some((p) => p.alias === project)) return; const def = projects.find((p) => p.is_default) ?? projects[0]; + // `setProject` clears the selection; the branch id is project-scoped too. setProject(def.alias); - }, [project, projectsQ.data, setProject]); + if (project) setBranchId(null); + }, [project, projectsQ.data, setProject, setBranchId]); const branches = branchesQ.data?.branches ?? []; const branchLabel = branchId diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx index e7d80b49..5398e7a0 100644 --- a/web/frontend/src/main.tsx +++ b/web/frontend/src/main.tsx @@ -6,11 +6,21 @@ import "./index.css"; const queryClient = new QueryClient({ defaultOptions: { + // networkMode "always": every request goes to the same localhost origin + // that served the SPA, so react-query's online/offline heuristic is + // never right here -- with the default "online" mode a browser that + // (correctly or spuriously) reports offline silently *pauses* queries + // and mutations instead of failing them, and the UI freezes with no + // spinner and no error while `kbagent serve` keeps working fine. queries: { + networkMode: "always", retry: 1, staleTime: 30_000, refetchOnWindowFocus: false, }, + mutations: { + networkMode: "always", + }, }, }); diff --git a/web/frontend/src/pages/Components.tsx b/web/frontend/src/pages/Components.tsx index 54178600..bfeb9dcf 100644 --- a/web/frontend/src/pages/Components.tsx +++ b/web/frontend/src/pages/Components.tsx @@ -1,18 +1,56 @@ import { useQuery } from "@tanstack/react-query"; -import { Sparkles } from "lucide-react"; -import { useState } from "react"; +import { BookOpen, Boxes, FileJson, Info, Sparkles } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { api } from "../api/client"; import { Drawer } from "../components/Drawer"; import { ErrorBox, Loading, PageTitle } from "../components/Empty"; -import { JsonView } from "../components/JsonView"; +import { KeyValueGrid } from "../components/KeyValueGrid"; +import { PillList } from "../components/PillList"; +import { RawDetail } from "../components/RawDetail"; import { useUIState } from "../state"; import type { Component } from "../types"; +import { useHashSelection } from "../useHashSelection"; + +// Module-level so the array identity is stable across renders (a fresh +// literal would re-run react-markdown's plugin pipeline on every render). +const MARKDOWN_PLUGINS = [remarkGfm]; interface ComponentsResp { components: Component[]; errors: Array>; } +/** + * `GET /components/{id}` — mirrors `ComponentService.get_component_detail`. + * + * Every field is optional on purpose: the payload is assembled from EITHER + * the AI Service documentation index or (on a 404 there) the project's own + * Storage component catalog, and the catalog path fills the AI-only fields + * with empty values. `documentation_source` is the discriminator that tells + * the two apart — nothing else has to branch on it. + */ +interface ComponentDetailPayload { + component_id?: string; + component_name?: string; + component_type?: string; + categories?: string[]; + flags?: string[]; + description?: string; + long_description?: string; + documentation_url?: string; + schema_summary?: { + property_count?: number; + required_count?: number; + has_row_schema?: boolean; + }; + examples_count?: number; + row_examples_count?: number; + project_alias?: string; + documentation_source?: string; +} + const PROMPT_HINTS = [ "I need to download data from marketing campaigns", "I want to write data to BigQuery", @@ -31,6 +69,8 @@ const TYPE_FILTERS: Array<{ id: string; label: string }> = [ export function ComponentsPage() { const { project } = useUIState(); + // Deep link: `?sel=` opens that component's detail drawer. + const [sel, setSel] = useHashSelection(); const [query, setQuery] = useState(""); const [type, setType] = useState(""); const [selected, setSelected] = useState(null); @@ -51,6 +91,35 @@ export function ComponentsPage() { const components = q.data?.components ?? []; + // Restore a deep-linked selection ONCE, after the first list load. Guarded + // by a ref rather than by `selected` so closing the drawer does not re-open + // it when the list refetches (typing in the search box refetches constantly). + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel || !project) { + restoredRef.current = true; + return; + } + if (q.isLoading) return; + restoredRef.current = true; + const hit = components.find((c) => c.component_id === sel); + // A shared link can point at a component the current filter hides (or one + // this project cannot see at all). Nothing to open, so drop the stale id + // rather than leaving `?sel=` pointing at a drawer that never appears. + if (hit) setSelected(hit); + else setSel(null); + }, [sel, project, q.isLoading, components, setSel]); + + const openComponent = (c: Component) => { + setSelected(c); + setSel(c.component_id); + }; + const closeComponent = () => { + setSelected(null); + setSel(null); + }; + return (
setSelected(c)} + onClick={() => openComponent(c)} className="nerd-card text-left hover:border-keboola/50 transition-colors" >
@@ -137,10 +206,10 @@ export function ComponentsPage() { {selected ? ( setSelected(null)} + onClose={closeComponent} title={selected.component_name} subtitle={selected.component_id} - width="max-w-3xl" + wide > @@ -151,7 +220,7 @@ export function ComponentsPage() { function ComponentDetail({ componentId }: { componentId: string }) { const { project } = useUIState(); - const q = useQuery({ + const q = useQuery({ queryKey: ["component-detail", componentId, project], queryFn: () => api.get(`/components/${encodeURIComponent(componentId)}`, { @@ -159,6 +228,153 @@ function ComponentDetail({ componentId }: { componentId: string }) { }), }); if (q.isLoading) return ; - if (q.error) return ; - return ; + if (q.error) { + const message = (q.error as Error).message; + return ( +
+ + {/not found/i.test(message) ? ( +
+ Neither the AI Service documentation index nor this project's Storage component + catalog knows {componentId}. Check the + id, or switch to a project that has the component enabled. +
+ ) : null} +
+ ); + } + if (!q.data) return null; + return } />; +} + +function ComponentOverview({ detail }: { detail: ComponentDetailPayload }) { + const schema = detail.schema_summary ?? {}; + const fromCatalog = detail.documentation_source === "storage_catalog"; + + return ( +
+ {/* The fallback is invisible in the payload's other fields (they are + simply empty), so say it out loud — otherwise a component with no AI + Service entry looks like one that ships no documentation at all. */} + {fromCatalog ? ( +
+ + + AI Service has no documentation for this component — showing the project's Storage + catalog entry. + +
+ ) : null} + +
} label="Component"> + + {detail.documentation_url} + + ) : ( + "" + ), + mono: true, + }, + { label: "Documentation source", value: detail.documentation_source, mono: true }, + ]} + /> + {detail.description ? ( +

{detail.description}

+ ) : null} +
+ +
} + label="Configuration schema & examples" + > + yes + ) : ( + no + ), + }, + { + label: "Root examples", + value: detail.examples_count != null ? String(detail.examples_count) : "", + mono: true, + }, + { + label: "Row examples", + value: detail.row_examples_count != null ? String(detail.row_examples_count) : "", + mono: true, + }, + ]} + /> +
+ +
+ +
+ +
+ +
+ + {detail.long_description ? ( +
} label="Documentation"> +
+ + {detail.long_description} + +
+
+ ) : null} +
+ ); +} + +/** Card with the icon + micro-label header used by the other detail drawers. */ +function Section({ + icon, + label, + children, +}: { + icon?: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+ {children} +
+ ); } diff --git a/web/frontend/src/pages/Configs.tsx b/web/frontend/src/pages/Configs.tsx index 42d81978..2354210d 100644 --- a/web/frontend/src/pages/Configs.tsx +++ b/web/frontend/src/pages/Configs.tsx @@ -1,13 +1,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { PlayCircle, RotateCcw, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { FileCode, Layers, PlayCircle, RotateCcw, SlidersHorizontal, Trash2 } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { ConfirmModal } from "../components/ConfirmModal"; import { Drawer } from "../components/Drawer"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; +import { type KeyValueItem, KeyValueGrid } from "../components/KeyValueGrid"; +import { PillList } from "../components/PillList"; +import { RawDetail } from "../components/RawDetail"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; +import { useHashSelection } from "../useHashSelection"; import type { ConfigSummary, ProjectError } from "../types"; interface ConfigsResp { @@ -15,6 +19,38 @@ interface ConfigsResp { errors: ProjectError[]; } +/** + * `GET /configs/{project}/{component}/{config}` — the Storage API's own + * configuration payload, flattened with `project_alias` + `branch_id` by + * `ConfigService.get_config_detail`. + * + * Every field is optional on purpose: the body below `configuration` is + * component-defined and older stacks omit blocks entirely, so the overview has + * to degrade to an em dash rather than render "undefined". + */ +interface ConfigDetailPayload { + id?: string; + name?: string; + description?: string; + created?: string; + creatorToken?: { id?: number | string; description?: string }; + version?: number; + changeDescription?: string; + isDisabled?: boolean; + isDeleted?: boolean; + configuration?: unknown; + rows?: Array>; + state?: unknown; + currentVersion?: { + created?: string; + creatorToken?: { id?: number | string; description?: string }; + changeDescription?: string; + versionIdentifier?: string; + }; + project_alias?: string; + branch_id?: number | null; +} + /** One row of `GET /configs/trash/{project}` (mirrors `shape_trash_entry`). */ interface TrashEntry { component_id: string; @@ -36,6 +72,8 @@ type ConfigsTab = "configs" | "trash"; export function ConfigsPage() { const { project, branchId } = useUIState(); + // Deep link: `?sel=/` opens that config's drawer. + const [sel, setSel] = useHashSelection(); const [tab, setTab] = useState("configs"); const [filterText, setFilterText] = useState(""); const [selected, setSelected] = useState(null); @@ -49,6 +87,45 @@ export function ConfigsPage() { enabled: !!project && tab === "configs", }); + // Restore a deep-linked selection ONCE, after the first list load. Guarded + // by a ref rather than by `selected`, so closing the drawer does not + // immediately re-open it on the next render. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel || !project) { + restoredRef.current = true; + return; + } + if (q.isLoading) return; + restoredRef.current = true; + const hit = q.data?.configs.find((c) => selKey(c.component_id, c.config_id) === sel); + // Unlike a job id, a config drawer cannot be opened from the id alone: + // the row carries the project alias the detail request is addressed to. + // A link to a config this project/branch does not have therefore drops + // the selection instead of opening a drawer that could only 404. + if (hit) setSelected(hit); + else setSel(null); + }, [sel, project, q.isLoading, q.data, setSel]); + + const openConfig = (c: ConfigSummary) => { + setSelected(c); + setSel(selKey(c.component_id, c.config_id)); + }; + const closeConfig = () => { + setSelected(null); + setSel(null); + }; + const switchTab = (t: ConfigsTab) => { + setTab(t); + // The trash rows are a different id space (and open no drawer), so a + // configs selection must not survive into that tab's URL. + if (t === "trash") { + setSelected(null); + setSel(null); + } + }; + const filtered = q.data?.configs.filter((c) => filterText @@ -70,7 +147,7 @@ export function ConfigsPage() { key={t} type="button" className={`nerd-btn ${tab === t ? "border-keboola text-keboola" : ""}`} - onClick={() => setTab(t)} + onClick={() => switchTab(t)} > {t === "trash" ? ( <> @@ -108,7 +185,7 @@ export function ConfigsPage() { `${c.project_alias}/${c.component_id}/${c.config_id}`} - onRowClick={(c) => setSelected(c)} + onRowClick={openConfig} columns={[ { header: "Component", cell: (c) => {c.component_id} }, { header: "Config ID", cell: (c) => {c.config_id} }, @@ -126,13 +203,24 @@ export function ConfigsPage() { componentId={selected.component_id} configId={selected.config_id} name={selected.config_name} - onClose={() => setSelected(null)} + onClose={closeConfig} /> ) : null}
); } +/** + * The `?sel=` value for one configuration. A component id contains dots but + * never a slash, and neither does a config id, so a single `/` is an + * unambiguous separator. Restore compares whole keys built by this function + * rather than splitting the URL value, so an unexpected extra `/` can only + * fail to match — never silently address a different configuration. + */ +function selKey(componentId: string, configId: string): string { + return `${componentId}/${configId}`; +} + /** * Trash view (#643). A `config delete` is a SOFT delete: the Storage API moves * the configuration here and it stays restorable. (The same DELETE issued at @@ -246,7 +334,7 @@ function ConfigDetail({ const [actionError, setActionError] = useState(null); const [startedJobId, setStartedJobId] = useState(null); - const detailQ = useQuery({ + const detailQ = useQuery({ queryKey: ["config-detail", alias, componentId, configId, branchId], queryFn: () => api.get( @@ -344,7 +432,12 @@ function ConfigDetail({ ) : null} {detailQ.isLoading ? : null} {detailQ.error ? : null} - {detailQ.data ? : null} + {detailQ.data ? ( + } + /> + ) : null}
{confirmDelete ? ( @@ -369,3 +462,146 @@ function ConfigDetail({ ); } + +/** + * Rendered body of a configuration detail. + * + * The metadata (who changed it, when, which version, is it disabled) is what a + * reader is usually after, and it used to be buried at the top of a raw JSON + * dump next to a component-defined `configuration` blob of arbitrary size. + * That blob stays visible verbatim -- it is inherently freeform, so summarizing + * it beyond naming its top-level blocks would be guessing -- but it no longer + * hides the fields around it. The untouched payload is one tab away. + */ +function ConfigOverview({ + detail, + componentId, +}: { + detail: ConfigDetailPayload; + componentId: string; +}) { + const configuration = isRecord(detail.configuration) ? detail.configuration : {}; + const configurationKeys = Object.keys(configuration); + const rows = detail.rows ?? []; + const state = isRecord(detail.state) ? detail.state : {}; + const hasState = Object.keys(state).length > 0; + + // `currentVersion` describes the version actually served; the top-level + // `changeDescription` is the same text on a fresh config but goes stale on + // older stacks, so it is only the fallback. + const lastChange = detail.currentVersion?.changeDescription ?? detail.changeDescription ?? ""; + const lastChangeAt = detail.currentVersion?.created ?? ""; + + const items: KeyValueItem[] = [ + { label: "Name", value: detail.name }, + { label: "Config ID", value: detail.id, mono: true }, + { label: "Component ID", value: componentId, mono: true }, + { label: "Version", value: detail.version != null ? `v${detail.version}` : "", mono: true }, + { label: "Created", value: detail.created }, + { label: "Created by", value: detail.creatorToken?.description }, + { + label: "Last change", + value: lastChange ? ( + <> + {lastChange} + {lastChangeAt ? ・ {lastChangeAt} : null} + + ) : ( + "" + ), + }, + { + label: "Branch", + value: detail.branch_id != null ? `#${detail.branch_id}` : "production", + mono: true, + }, + ]; + // Only when it carries something: an empty description is the norm, and a + // permanent em-dash cell would read as a missing field rather than a blank. + if (detail.description) items.push({ label: "Description", value: detail.description }); + + return ( +
+
} label="Configuration"> + + {detail.isDisabled || detail.isDeleted ? ( +
+ {detail.isDisabled ? : null} + {detail.isDeleted ? : null} +
+ ) : null} +
+ +
} label="Parameters"> + + {configurationKeys.length > 0 ? ( +
+ +
+ ) : null} +
+ + {rows.length > 0 ? ( +
} label={`Rows (${rows.length})`}> + String(r.id ?? "")} + columns={[ + { header: "Row ID", cell: (r) => {String(r.id ?? "")} }, + { + header: "Name", + cell: (r) => {String(r.name ?? "")}, + }, + { + header: "Status", + align: "right", + cell: (r) => + r.isDisabled ? ( + disabled + ) : ( + enabled + ), + }, + ]} + /> +
+ ) : null} + + {hasState ? ( +
+ state + +
+ ) : null} +
+ ); +} + +/** Card with the icon + micro-label header used by the other detail drawers. */ +function Section({ + icon, + label, + children, +}: { + icon?: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+ {children} +
+ ); +} + +/** A JSON object (not an array, not null) — the shape both blobs must be. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/web/frontend/src/pages/DataApps.tsx b/web/frontend/src/pages/DataApps.tsx index 89b622a7..580dc92d 100644 --- a/web/frontend/src/pages/DataApps.tsx +++ b/web/frontend/src/pages/DataApps.tsx @@ -1,18 +1,63 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Pause, Play, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { Check, Copy, Pause, Play, RefreshCw, Rocket, Trash2 } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; +import { ConfirmModal } from "../components/ConfirmModal"; +import { DetailTabs } from "../components/DetailTabs"; +import { Drawer } from "../components/Drawer"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; +import { KeyValueGrid } from "../components/KeyValueGrid"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; import type { DataApp, ProjectError } from "../types"; +import { useHashSelection } from "../useHashSelection"; interface DataAppsResp { apps: DataApp[]; errors: ProjectError[]; } +/** + * `GET /data-apps/{project}/{app_id}` — mirrors `DataAppService.get_data_app`, + * which merges the Data Science deployment record (state / url / deployed + * version) with the Storage configuration (name / description / slug / git). + * Every field is optional: an app whose Storage config was deleted still has a + * deployment record, and the merge simply leaves those keys empty. + */ +interface DataAppDetailPayload { + project_alias?: string; + app_id?: string; + config_id?: string; + config_version_storage?: string; + config_version_deployed?: string; + name?: string; + description?: string; + type?: string; + state?: string; + desired_state?: string; + url?: string; + size?: string; + auto_suspend_after_seconds?: number | null; + last_start_timestamp?: string | null; + slug?: string; + git?: Record; + raw?: Record; +} + +/** `GET /data-apps/{project}/{app_id}/logs` — mirrors `get_app_logs`. */ +interface DataAppLogsPayload { + project_alias?: string; + app_id?: string; + lines_requested?: number | null; + since_requested?: string | null; + lines_returned?: number; + text?: string; +} + +/** Tail depth requested by the Logs tab. The route caps nothing by default. */ +const LOG_TAIL_LINES = 200; + const STATE_STYLE: Record = { running: "nerd-pill-green", starting: "nerd-pill-amber", @@ -21,10 +66,22 @@ const STATE_STYLE: Record = { error: "nerd-pill-red", }; +function statePill(state: string | undefined): ReactNode { + if (!state) return ""; + return {state}; +} + export function DataAppsPage() { const { project, branchId } = useUIState(); const qc = useQueryClient(); + // Deep link: `?sel=` opens that app's detail drawer. App ids are + // globally unique platform-side, so the alias never has to be part of it + // even though the listing spans projects. + const [sel, setSel] = useHashSelection(); const [selected, setSelected] = useState(null); + // App pending a delete confirmation (row trash button). + const [confirmApp, setConfirmApp] = useState(null); + const q = useQuery({ queryKey: ["data-apps", project, branchId], queryFn: () => @@ -44,8 +101,44 @@ export function DataAppsPage() { const delMu = useMutation({ mutationFn: ({ alias, appId }: { alias: string; appId: string }) => api.delete(`/data-apps/${encodeURIComponent(alias)}/${encodeURIComponent(appId)}`), - onSuccess: () => qc.invalidateQueries({ queryKey: ["data-apps"] }), + onSuccess: (_res, vars) => { + // The drawer may be showing the app that was just deleted. + if (selected?.app_id === vars.appId) closeApp(); + qc.invalidateQueries({ queryKey: ["data-apps"] }); + }, }); + + const apps = q.data?.apps ?? []; + + // Restore a deep-linked selection ONCE, after the first list load. Guarded + // by a ref rather than by `selected` so closing the drawer does not re-open + // it the next time the list is invalidated by a start/stop mutation. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel || !project) { + restoredRef.current = true; + return; + } + if (q.isLoading) return; + restoredRef.current = true; + const hit = apps.find((a) => a.app_id === sel); + // The drawer needs the project alias to fetch anything, and that only + // comes from the row — a link to an app outside the current project + // selection cannot be resolved, so drop the stale id. + if (hit) setSelected(hit); + else setSel(null); + }, [sel, project, q.isLoading, apps, setSel]); + + const openApp = (a: DataApp) => { + setSelected(a); + setSel(a.app_id); + }; + function closeApp() { + setSelected(null); + setSel(null); + } + return (
@@ -57,9 +150,9 @@ export function DataAppsPage() { ) : ( `${a.project_alias}/${a.app_id}`} - onRowClick={(a) => setSelected(a)} + onRowClick={openApp} columns={[ { header: "App", cell: (a) => {a.name} }, { header: "ID", cell: (a) => {a.app_id} }, @@ -103,8 +196,7 @@ export function DataAppsPage() { className="nerd-btn text-xs hover:text-red-400 hover:border-red-700" onClick={(e) => { e.stopPropagation(); - if (confirm(`Delete app ${a.name}?`)) - delMu.mutate({ alias: a.project_alias, appId: a.app_id }); + setConfirmApp(a); }} > @@ -115,16 +207,264 @@ export function DataAppsPage() { ]} /> )} - {selected ? ( -
-
-

{selected.name}

- -
- + + {selected ? : null} + + {confirmApp ? ( + + {confirmApp.name} ( + {confirmApp.app_id}) is deleted from the Keboola + platform together with its configuration. This is not reversible + from here — the app URL stops resolving and the deployment record is gone. + + } + items={[`${confirmApp.project_alias} / ${confirmApp.app_id}`]} + confirmLabel="Delete app" + onConfirm={() => + delMu.mutate( + { alias: confirmApp.project_alias, appId: confirmApp.app_id }, + { onSettled: () => setConfirmApp(null) }, + ) + } + onCancel={() => setConfirmApp(null)} + /> + ) : null} +
+ ); +} + +function DataAppDrawer({ app, onClose }: { app: DataApp; onClose: () => void }) { + const { branchId } = useUIState(); + const [tab, setTab] = useState("overview"); + + const detailQ = useQuery({ + queryKey: ["data-app-detail", app.project_alias, app.app_id, branchId], + queryFn: () => + api.get( + `/data-apps/${encodeURIComponent(app.project_alias)}/${encodeURIComponent(app.app_id)}`, + { query: { branch_id: branchId ?? undefined } }, + ), + }); + + const subtitle = `${app.project_alias} ・ ${app.app_id}`; + + return ( + + + {tab === "logs" ? ( + + ) : detailQ.isLoading ? ( + + ) : detailQ.error ? ( + + ) : !detailQ.data ? null : tab === "overview" ? ( + + ) : ( +
+ +
+ )} +
+ ); +} + +function DataAppOverview({ + detail, + fallback, + branchId, +}: { + detail: DataAppDetailPayload; + /** The list row — used for the few fields the detail merge may leave empty. */ + fallback: DataApp; + branchId: number | null; +}) { + const url = detail.url || fallback.url; + const autoSuspend = detail.auto_suspend_after_seconds; + + return ( +
+
} label="App"> + + {detail.description ? ( +

{detail.description}

+ ) : null} + {url ? ( + + open app → + + ) : null} +
+
+ ); +} + +function DataAppLogs({ alias, appId }: { alias: string; appId: string }) { + const q = useQuery({ + queryKey: ["data-app-logs", alias, appId], + queryFn: () => + api.get( + `/data-apps/${encodeURIComponent(alias)}/${encodeURIComponent(appId)}/logs`, + { query: { lines: LOG_TAIL_LINES } }, + ), + // Tab-activated: the endpoint answers HTTP 400 for an app that was never + // deployed, so it must not fire just because the drawer opened. + refetchOnWindowFocus: false, + }); + + return ( +
+
+ + + last {LOG_TAIL_LINES} lines + {q.data?.lines_returned != null ? ` — ${q.data.lines_returned} returned` : ""} + +
+ {q.isLoading ? : null} + {/* A never-deployed app has no container, and the Data Science endpoint + answers with an error rather than an empty buffer — surface it as-is + instead of rendering a blank pane that looks like "no output". */} + {q.error ? : null} + {q.data ? ( + q.data.text ? ( +
+            {q.data.text}
+          
+ ) : ( +
Container log buffer is empty.
+ ) + ) : null} +
+ ); +} + +/** Card with the icon + micro-label header used by the other detail drawers. */ +function Section({ + icon, + label, + children, +}: { + icon?: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+ {children} +
+ ); +} + +/** + * Local twin of the copy button `RawDetail` renders on its Raw tab (that one + * is private to the module). Same contract: `navigator.clipboard` is undefined + * on a non-secure origin — kbagent serve is plain http by default — so the + * button degrades to a "select it manually" hint instead of throwing. + */ +function CopyJsonButton({ data }: { data: unknown }) { + const [state, setState] = useState<"idle" | "copied" | "manual">("idle"); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const onCopy = () => { + const clip = navigator.clipboard; + if (!clip || typeof clip.writeText !== "function") { + setState("manual"); + return; + } + clip.writeText(JSON.stringify(data, null, 2)).then( + () => { + setState("copied"); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setState("idle"), 2000); + }, + () => setState("manual"), + ); + }; + + return ( +
+ + {state === "manual" ? ( + + Clipboard unavailable (non-secure origin) — select the JSON below and copy manually. + ) : null}
); diff --git a/web/frontend/src/pages/Flows.tsx b/web/frontend/src/pages/Flows.tsx index 44e14c12..12ecc908 100644 --- a/web/frontend/src/pages/Flows.tsx +++ b/web/frontend/src/pages/Flows.tsx @@ -7,6 +7,7 @@ import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; +import { useHashSelection } from "../useHashSelection"; import type { Flow, ProjectError } from "../types"; interface Phase { @@ -62,6 +63,8 @@ interface NotificationsResp { export function FlowsPage() { const { project, branchId } = useUIState(); + // Deep link: `?sel=` opens that flow's drawer. + const [sel, setSel] = useHashSelection(); const [selected, setSelected] = useState(null); const q = useQuery({ queryKey: ["flows", project, branchId], @@ -71,6 +74,32 @@ export function FlowsPage() { }), enabled: !!project, }); + + // Restore a deep-linked flow ONCE, after the first list load. A link to a + // flow that no longer exists (or lives on another branch) leaves the list + // open rather than erroring. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel) { + restoredRef.current = true; + return; + } + if (!q.data) return; + restoredRef.current = true; + const hit = q.data.flows.find((f) => f.config_id === sel); + if (hit) setSelected(hit); + }, [sel, q.data]); + + const openFlow = (f: Flow) => { + setSelected(f); + setSel(f.config_id); + }; + const closeFlow = () => { + setSelected(null); + setSel(null); + }; + return (
@@ -84,7 +113,7 @@ export function FlowsPage() { `${f.project_alias}/${f.component_id}/${f.config_id}`} - onRowClick={(f) => setSelected(f)} + onRowClick={openFlow} columns={[ { header: "Name", cell: (f) => {f.name} }, { header: "Component", cell: (f) => {f.component_id} }, @@ -107,7 +136,7 @@ export function FlowsPage() { /> )} {selected ? ( - setSelected(null)} /> + ) : null}
); diff --git a/web/frontend/src/pages/Jobs.tsx b/web/frontend/src/pages/Jobs.tsx index beb6036c..4a3bfa45 100644 --- a/web/frontend/src/pages/Jobs.tsx +++ b/web/frontend/src/pages/Jobs.tsx @@ -20,6 +20,7 @@ import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; +import { useHashSelection } from "../useHashSelection"; import type { Job, ProjectError } from "../types"; interface JobsResp { @@ -68,6 +69,8 @@ function jobBranchId(job: Job): number | undefined { export function JobsPage() { const { project } = useUIState(); + // Deep link: `?sel=` opens that job's detail drawer. + const [sel, setSel] = useHashSelection(); const [statusFilter, setStatusFilter] = useState(null); const [selected, setSelected] = useState(null); @@ -85,6 +88,55 @@ export function JobsPage() { refetchInterval: 8000, }); + // Restore a deep-linked selection ONCE, after the first list load. Guarded + // by a ref rather than by `selected`, so closing the drawer does not + // immediately re-open it on the next poll. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel || !project) { + restoredRef.current = true; + return; + } + if (q.isLoading) return; + restoredRef.current = true; + const hit = q.data?.jobs.find((j) => String(j.id) === sel); + if (hit) { + setSelected(hit); + return; + } + if (q.data) { + // The list is capped at 100 rows, so a shared link to an older job will + // miss. The drawer fetches its own detail by id anyway, so fall back to a + // minimal row: the header stays sparse until that detail lands, and the + // row-level actions (which need the component/config) stay hidden. + setSelected({ + project_alias: project, + id: sel, + status: "", + component: "", + config: null, + createdTime: "", + }); + } else { + // The list itself errored -- typically a foreign link whose project + // alias this install does not know (TopBar is about to fall back to + // the default project). Opening the synthetic drawer here would pin an + // errored detail fetch to a project that is being swapped away, so + // drop the deep link instead. + setSel(null); + } + }, [sel, setSel, project, q.isLoading, q.data]); + + const openJob = (j: Job) => { + setSelected(j); + setSel(String(j.id)); + }; + const closeJob = () => { + setSelected(null); + setSel(null); + }; + return (
String(j.id)} - onRowClick={(j) => setSelected(j)} + onRowClick={openJob} columns={[ { header: "Job ID", cell: (j) => {j.id} }, { @@ -151,7 +203,7 @@ export function JobsPage() { /> )} - {selected ? setSelected(null)} /> : null} + {selected ? : null}
); } diff --git a/web/frontend/src/pages/Projects.tsx b/web/frontend/src/pages/Projects.tsx index 28c25f27..3b44a5be 100644 --- a/web/frontend/src/pages/Projects.tsx +++ b/web/frontend/src/pages/Projects.tsx @@ -1,12 +1,26 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { CheckCircle2, Plus, RefreshCw, Trash2, XCircle } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + CheckCircle2, + Flag, + Gauge, + KeyRound, + Plus, + RefreshCw, + Server, + Trash2, + XCircle, +} from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { ApiError, api } from "../api/client"; import { ConfirmModal } from "../components/ConfirmModal"; +import { Drawer } from "../components/Drawer"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; -import { JsonView } from "../components/JsonView"; +import { KeyValueGrid } from "../components/KeyValueGrid"; +import { PillList } from "../components/PillList"; +import { RawDetail } from "../components/RawDetail"; import { DataTable } from "../components/Table"; import type { Project, ProjectStatus } from "../types"; +import { useHashSelection } from "../useHashSelection"; interface BulkDeleteResult { removed: string[]; @@ -14,9 +28,35 @@ interface BulkDeleteResult { dry_run: boolean; } +/** + * `GET /projects/{alias}/info` — mirrors `ProjectService.get_info`, which + * reshapes `/v2/storage/tokens/verify`. Everything past `stack_url` comes + * straight from the stack, so each field is optional: an older stack may omit + * it entirely and the overview must degrade rather than render "undefined". + */ +interface ProjectInfoPayload { + alias?: string; + project_id?: number | null; + project_name?: string; + stack_url?: string; + auth_mode?: string; + default_backend?: string; + features?: string[]; + // Storage returns each limit as `{name, value}`, but older stacks (and some + // limits) send the bare scalar — both shapes are unpacked by `limitValue`. + limits?: Record; + metrics?: Record; + token_id?: string; + token_description?: string; + is_master_token?: boolean; + token_expires?: string | null; +} + export function ProjectsPage() { const qc = useQueryClient(); const [showAdd, setShowAdd] = useState(false); + // Deep link: `?sel=` opens that project's detail drawer. + const [sel, setSel] = useHashSelection(); const [selected, setSelected] = useState(null); const [selectedAliases, setSelectedAliases] = useState>(new Set()); // Aliases pending a remove-confirmation (single trash button or bulk action). @@ -52,7 +92,10 @@ export function ProjectsPage() { onSuccess: (res) => { setSelectedAliases(new Set()); // The detail pane may be showing a project that was just removed. - if (selected && res.removed.includes(selected.alias)) setSelected(null); + if (selected && res.removed.includes(selected.alias)) { + setSelected(null); + setSel(null); + } qc.invalidateQueries({ queryKey: ["projects"] }); if (res.failed.length > 0) { const lines = res.failed.map((f) => `${f.alias} (${f.error})`).join(", "); @@ -72,6 +115,32 @@ export function ProjectsPage() { const projects = projectsQ.data?.projects ?? []; + // Restore a deep-linked selection ONCE, after the first list load. Guarded + // by a ref rather than by `selected`, so closing the drawer is not undone + // by a later refetch. An alias this config does not know clears the link. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel) { + restoredRef.current = true; + return; + } + if (projectsQ.isLoading) return; + restoredRef.current = true; + const hit = projects.find((p) => p.alias === sel); + if (hit) setSelected(hit); + else setSel(null); + }, [sel, setSel, projects, projectsQ.isLoading]); + + const openProject = (p: Project) => { + setSelected(p); + setSel(p.alias); + }; + const closeProject = () => { + setSelected(null); + setSel(null); + }; + // Keep the selection in sync with the live project list: drop any alias that // no longer exists (e.g. removed in another tab) so stale keys never linger. useEffect(() => { @@ -178,7 +247,7 @@ export function ProjectsPage() { p.alias} - onRowClick={(p) => setSelected(p)} + onRowClick={openProject} selectedKeys={selectedAliases} onToggleRow={toggleRow} onToggleAll={toggleAll} @@ -284,15 +353,7 @@ export function ProjectsPage() { )} {selected ? ( -
-
-

Project: {selected.alias}

- -
- -
+ ) : null} {confirmAliases ? ( @@ -322,14 +383,169 @@ export function ProjectsPage() { ); } -function ProjectInfo({ alias }: { alias: string }) { - const infoQ = useQuery>({ - queryKey: ["project-info", alias], - queryFn: () => api.get(`/projects/${encodeURIComponent(alias)}/info`), +function ProjectDetailDrawer({ + project, + onClose, +}: { + project: Project; + onClose: () => void; +}) { + const infoQ = useQuery({ + queryKey: ["project-info", project.alias], + queryFn: () => api.get(`/projects/${encodeURIComponent(project.alias)}/info`), }); - if (infoQ.isLoading) return ; - if (infoQ.error) return ; - return ; + + const subtitle = [project.project_name, project.project_id != null ? `#${project.project_id}` : null] + .filter(Boolean) + .join(" ・ "); + + return ( + + {infoQ.isLoading ? : null} + {infoQ.error ? : null} + {infoQ.data ? ( + } + /> + ) : null} + + ); +} + +function ProjectOverview({ + info, + project, +}: { + info: ProjectInfoPayload; + project: Project; +}) { + const stackUrl = info.stack_url ?? project.stack_url; + const projectId = info.project_id ?? project.project_id; + // The admin URL only resolves with a numeric id; a project registered + // against a stack that never returned one gets no link rather than a 404. + const adminUrl = + stackUrl && projectId != null + ? `${stackUrl.replace(/\/+$/, "")}/admin/projects/${projectId}` + : null; + const limits = Object.entries(info.limits ?? {}); + + return ( +
+
} label="Project"> + + {stackUrl} + + ) : ( + "" + ), + mono: true, + }, + { label: "Auth mode", value: info.auth_mode }, + { label: "Default backend", value: info.default_backend }, + { label: "Organization", value: project.org_name ?? orgLabel(project.org_id) }, + ]} + /> + {adminUrl ? ( + + open in Keboola UI → + + ) : null} +
+ +
} label="Token"> + master + ) : ( + scoped + ), + }, + { label: "Expires", value: info.token_expires ?? "never", mono: true }, + { label: "Masked value", value: project.token, mono: true }, + ]} + /> +
+ +
} label={`Features (${info.features?.length ?? 0})`}> + +
+ + {limits.length > 0 ? ( +
} label={`Limits (${limits.length})`}> + + + {limits.map(([name, raw]) => ( + + + + + ))} + +
{name} + {limitValue(raw)} +
+
+ ) : null} +
+ ); +} + +/** Card with the icon + micro-label header used by the Jobs detail drawer. */ +function Section({ + icon, + label, + children, +}: { + icon?: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+ {children} +
+ ); +} + +function orgLabel(orgId: number | null): string { + return orgId != null ? `#${orgId}` : ""; +} + +/** Storage sends a limit as either `{name, value}` or the bare scalar. */ +function limitValue(raw: unknown): string { + const value = + raw !== null && typeof raw === "object" && "value" in raw + ? (raw as { value: unknown }).value + : raw; + if (value === null || value === undefined) return "—"; + if (typeof value === "number") return value.toLocaleString(); + if (typeof value === "object") return JSON.stringify(value); + return String(value); } function AddProject({ onDone }: { onDone: () => void }) { diff --git a/web/frontend/src/pages/Storage.tsx b/web/frontend/src/pages/Storage.tsx index c81a432b..11781ced 100644 --- a/web/frontend/src/pages/Storage.tsx +++ b/web/frontend/src/pages/Storage.tsx @@ -11,15 +11,46 @@ import { Trash2, X, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { api, ApiError } from "../api/client"; import { Drawer } from "../components/Drawer"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; +import { useHashSelection } from "../useHashSelection"; import type { Branch, Bucket, ProjectError, Table as TableT } from "../types"; +type StorageTab = "buckets" | "tables" | "files"; + +const STORAGE_TABS: readonly StorageTab[] = ["buckets", "tables", "files"]; + +/** + * This page's `?sel=` grammar: `` or `tables/`. + * + * The tab is part of the selection because it is what the link has to restore + * before anything can be opened -- the tables query is gated on it. Only the + * tables tab has a detail view (the table drawer), so it is the only one that + * carries an object id. The bucket FILTER is deliberately not encoded: it is a + * transient narrowing of the same list, not a selected object. + */ +function parseStorageSel(sel: string | null): { tab: StorageTab; tableId: string | null } { + if (!sel) return { tab: "buckets", tableId: null }; + const slash = sel.indexOf("/"); + const head = slash === -1 ? sel : sel.slice(0, slash); + const rest = slash === -1 ? "" : sel.slice(slash + 1); + const tab = (STORAGE_TABS as readonly string[]).includes(head) + ? (head as StorageTab) + : "buckets"; + return { tab, tableId: tab === "tables" && rest ? rest : null }; +} + +function buildStorageSel(tab: StorageTab, tableId: string | null): string | null { + if (tab === "tables" && tableId) return `tables/${tableId}`; + // The landing view needs no `sel` at all -- keeps a plain project link clean. + return tab === "buckets" ? null : tab; +} + interface TablePreview { header: string[]; rows: string[][]; @@ -89,10 +120,27 @@ function formatBytes(n: number): string { export function StoragePage() { const { project, branchId } = useUIState(); - const [tab, setTab] = useState<"buckets" | "tables" | "files">("buckets"); + // Deep link: `?sel=tables/` restores the tab AND opens the drawer. + const [sel, setSel] = useHashSelection(); + const [tab, setTabState] = useState(() => parseStorageSel(sel).tab); const [bucketFilter, setBucketFilter] = useState(null); const [selectedTable, setSelectedTable] = useState(null); + // Switching tabs drops the open table: the drawer belongs to the tables tab. + const setTab = (t: StorageTab) => { + setTabState(t); + setSelectedTable(null); + setSel(buildStorageSel(t, null)); + }; + const openTable = (t: TableT) => { + setSelectedTable(t); + setSel(buildStorageSel("tables", t.id)); + }; + const closeTable = () => { + setSelectedTable(null); + setSel(buildStorageSel(tab, null)); + }; + const bucketsQ = useQuery({ queryKey: ["buckets", project, branchId], queryFn: () => @@ -112,6 +160,23 @@ export function StoragePage() { enabled: !!project && tab === "tables", }); + // Restore a deep-linked table ONCE, after the first tables load. The list is + // unfiltered, so every table in the project is a candidate; a link to a table + // that no longer exists simply leaves the list open. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + const wanted = parseStorageSel(sel).tableId; + if (!wanted) { + restoredRef.current = true; + return; + } + if (!tablesQ.data) return; + restoredRef.current = true; + const hit = tablesQ.data.tables.find((t) => t.id === wanted); + if (hit) setSelectedTable(hit); + }, [sel, tablesQ.data]); + return (
- {(["buckets", "tables", "files"] as const).map((t) => ( + {STORAGE_TABS.map((t) => (
); } diff --git a/web/frontend/src/pages/Streams.tsx b/web/frontend/src/pages/Streams.tsx index 81c03ce9..497ddf8e 100644 --- a/web/frontend/src/pages/Streams.tsx +++ b/web/frontend/src/pages/Streams.tsx @@ -1,12 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Copy, Eye, EyeOff, Plus, Radio, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { Drawer } from "../components/Drawer"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; import { DataTable } from "../components/Table"; import { useUIState } from "../state"; +import { useHashSelection } from "../useHashSelection"; import type { DataStreamDetail, DataStreamSource } from "../types"; /** @@ -37,6 +38,8 @@ function branchRef(branchId: number | null): string | undefined { export function StreamsPage() { const { project, branchId } = useUIState(); const qc = useQueryClient(); + // Deep link: `?sel=` opens that source's detail drawer. + const [sel, setSel] = useHashSelection(); const [selected, setSelected] = useState(null); const [showCreate, setShowCreate] = useState(false); @@ -49,6 +52,30 @@ export function StreamsPage() { enabled: !!project, }); + // Restore a deep-linked source ONCE, after the first list load. A link to a + // deleted source just leaves the list open. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel) { + restoredRef.current = true; + return; + } + if (!q.data) return; + restoredRef.current = true; + const hit = q.data.sources.find((s) => s.source_id === sel); + if (hit) setSelected(hit); + }, [sel, q.data]); + + const openSource = (s: DataStreamSource) => { + setSelected(s); + setSel(s.source_id); + }; + const closeSource = () => { + setSelected(null); + setSel(null); + }; + const deleteMu = useMutation({ // `stream delete` is exposed as POST /delete (not HTTP DELETE) so the // dry-run flag can ride in the body alongside the source id. @@ -59,7 +86,7 @@ export function StreamsPage() { }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["streams"] }); - setSelected(null); + closeSource(); }, }); @@ -92,7 +119,7 @@ export function StreamsPage() { s.source_id} - onRowClick={(s) => setSelected(s)} + onRowClick={openSource} emptyMessage="No Data Streams sources yet. Create one to get an OTLP ingest endpoint." columns={[ { @@ -152,7 +179,7 @@ export function StreamsPage() { // Open the detail drawer for the new source. If the list hasn't // refetched yet we synthesize a minimal row -- the drawer fetches // its own full detail by source_id regardless. - setSelected( + openSource( created ?? { source_id: sourceId, name: sourceId, @@ -169,7 +196,7 @@ export function StreamsPage() { project={project} branchId={branchId} source={selected} - onClose={() => setSelected(null)} + onClose={closeSource} onDelete={(sourceId) => { if (confirm(`Delete Data Stream source '${sourceId}'?`)) { deleteMu.mutate(sourceId); diff --git a/web/frontend/src/router.test.ts b/web/frontend/src/router.test.ts new file mode 100644 index 00000000..46b82b6e --- /dev/null +++ b/web/frontend/src/router.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { buildHash, DEFAULT_PAGE, parseHash, type RouteState } from "./router"; +import { PAGE_IDS } from "./state"; + +const EMPTY: RouteState = { page: "dashboard", project: null, branchId: null, sel: null }; + +function route(over: Partial): RouteState { + return { ...EMPTY, ...over }; +} + +describe("parseHash", () => { + it("falls back to the dashboard for an empty hash", () => { + expect(parseHash("")).toEqual(EMPTY); + expect(parseHash("#")).toEqual(EMPTY); + expect(parseHash("#/")).toEqual(EMPTY); + expect(parseHash("#///")).toEqual(EMPTY); + }); + + it("parses a bare page", () => { + expect(parseHash("#/doctor")).toEqual(route({ page: "doctor" })); + expect(parseHash("#/semantic-layer")).toEqual(route({ page: "semantic-layer" })); + }); + + it("accepts a hash with or without the leading marker", () => { + expect(parseHash("/jobs")).toEqual(parseHash("#/jobs")); + }); + + it("tolerates a trailing slash", () => { + expect(parseHash("#/jobs/")).toEqual(route({ page: "jobs" })); + expect(parseHash("#/p/acme/jobs/")).toEqual(route({ page: "jobs", project: "acme" })); + }); + + it("parses a project-scoped page", () => { + expect(parseHash("#/p/acme/storage")).toEqual(route({ page: "storage", project: "acme" })); + }); + + it("keeps the project when the page part is missing", () => { + expect(parseHash("#/p/acme")).toEqual(route({ page: DEFAULT_PAGE, project: "acme" })); + expect(parseHash("#/p/acme/")).toEqual(route({ page: DEFAULT_PAGE, project: "acme" })); + }); + + it("treats a bare /p as no project at all", () => { + expect(parseHash("#/p")).toEqual(EMPTY); + expect(parseHash("#/p/")).toEqual(EMPTY); + }); + + it("does not shift an empty project slot into the page slot", () => { + // `#/p//jobs` must NOT parse `jobs` as the project alias. + expect(parseHash("#/p//jobs")).toEqual(route({ page: "jobs", project: null })); + }); + + it("parses the branch query param", () => { + expect(parseHash("#/p/acme/configs?branch=1234")).toEqual( + route({ page: "configs", project: "acme", branchId: 1234 }), + ); + }); + + it("rejects a non-positive-integer branch", () => { + for (const raw of ["abc", "0", "-5", "1.5", "12a", ""]) { + expect(parseHash(`#/p/acme/configs?branch=${raw}`).branchId).toBeNull(); + } + }); + + it("parses the sel query param", () => { + expect(parseHash("#/p/acme/jobs?sel=1234567890")).toEqual( + route({ page: "jobs", project: "acme", sel: "1234567890" }), + ); + }); + + it("decodes a sel containing slashes", () => { + expect(parseHash("#/p/acme/storage?sel=tables%2Fin.c-main.orders").sel).toBe( + "tables/in.c-main.orders", + ); + }); + + it("decodes a sel containing reserved and unicode characters", () => { + const encoded = encodeURIComponent("keboola.ex-db-snowflake/01ky4 pga?&#=+ěš"); + expect(parseHash(`#/p/acme/configs?sel=${encoded}`).sel).toBe( + "keboola.ex-db-snowflake/01ky4 pga?&#=+ěš", + ); + }); + + it("treats an empty sel as no selection", () => { + expect(parseHash("#/p/acme/jobs?sel=").sel).toBeNull(); + }); + + it("parses branch and sel together, in any order", () => { + const a = parseHash("#/p/acme/storage?branch=42&sel=tables%2Ft1"); + const b = parseHash("#/p/acme/storage?sel=tables%2Ft1&branch=42"); + expect(a).toEqual(route({ page: "storage", project: "acme", branchId: 42, sel: "tables/t1" })); + expect(b).toEqual(a); + }); + + it("ignores unknown query params", () => { + expect(parseHash("#/p/acme/jobs?foo=bar&branch=7")).toEqual( + route({ page: "jobs", project: "acme", branchId: 7 }), + ); + }); + + it("decodes an encoded project alias", () => { + expect(parseHash("#/p/my%20proj%2Fa/jobs")).toEqual( + route({ page: "jobs", project: "my proj/a" }), + ); + }); + + it("survives malformed percent escapes", () => { + expect(parseHash("#/p/100%/jobs")).toEqual(route({ page: "jobs", project: "100%" })); + }); + + it("falls back to the dashboard for an unknown page", () => { + expect(parseHash("#/nope")).toEqual(EMPTY); + // ... while keeping the project context, so the top bar does not reset. + expect(parseHash("#/p/acme/nope?branch=9")).toEqual( + route({ page: DEFAULT_PAGE, project: "acme", branchId: 9 }), + ); + }); + + it("never throws on garbage", () => { + for (const junk of ["#!!!", "#/?&&=", "#%%%", "#/p/%/%/%", "#////?branch=", "#?sel=x"]) { + expect(() => parseHash(junk)).not.toThrow(); + expect(PAGE_IDS).toContain(parseHash(junk).page); + } + }); +}); + +describe("buildHash", () => { + it("renders a bare page", () => { + expect(buildHash(route({ page: "doctor" }))).toBe("#/doctor"); + }); + + it("renders a project-scoped page", () => { + expect(buildHash(route({ page: "storage", project: "acme" }))).toBe("#/p/acme/storage"); + }); + + it("renders branch and sel", () => { + expect( + buildHash(route({ page: "storage", project: "acme", branchId: 42, sel: "tables/t1" })), + ).toBe("#/p/acme/storage?branch=42&sel=tables%2Ft1"); + }); + + it("omits an empty selection", () => { + expect(buildHash(route({ page: "jobs", project: "acme", sel: "" }))).toBe("#/p/acme/jobs"); + }); + + it("encodes the project alias", () => { + expect(buildHash(route({ page: "jobs", project: "my proj/a" }))).toBe( + "#/p/my%20proj%2Fa/jobs", + ); + }); + + it("encodes spaces as %20, not +", () => { + const hash = buildHash(route({ page: "jobs", project: "acme", sel: "a b" })); + expect(hash).toBe("#/p/acme/jobs?sel=a%20b"); + expect(hash).not.toContain("+"); + }); +}); + +describe("round trips", () => { + const cases: RouteState[] = [ + EMPTY, + route({ page: "doctor" }), + route({ page: "jobs", project: "acme" }), + route({ page: "jobs", project: "acme", branchId: 1234 }), + route({ page: "jobs", project: "acme", sel: "1122334455" }), + route({ page: "storage", project: "acme", sel: "tables/in.c-main.orders" }), + route({ page: "storage", project: "acme", branchId: 7, sel: "buckets" }), + route({ + page: "configs", + project: "acme", + branchId: 99, + sel: "keboola.ex-db-snowflake/01ky4pga8x9", + }), + route({ page: "flows", project: "p/roj ekt", sel: "a/b c?d&e=f#g+h" }), + route({ page: "stream", project: "ěščř", sel: "zdroj/1" }), + ]; + + for (const c of cases) { + it(`parseHash(buildHash(x)) === x for ${JSON.stringify(c)}`, () => { + expect(parseHash(buildHash(c))).toEqual(c); + }); + } + + it("is stable across a second pass", () => { + for (const c of cases) { + const once = buildHash(c); + expect(buildHash(parseHash(once))).toBe(once); + } + }); + + it("round trips every known page id", () => { + for (const page of PAGE_IDS) { + const r = route({ page, project: "acme", branchId: 5, sel: "x/y" }); + expect(parseHash(buildHash(r))).toEqual(r); + } + }); +}); diff --git a/web/frontend/src/router.ts b/web/frontend/src/router.ts new file mode 100644 index 00000000..96ff6343 --- /dev/null +++ b/web/frontend/src/router.ts @@ -0,0 +1,128 @@ +/** + * Hash-based routing: the pure URL <-> UI-state translation. + * + * Why the hash and not the History API: `kbagent serve` mounts this SPA at the + * root of the SAME FastAPI app that serves the REST API (`GET /projects` + * returns JSON, not the shell), so a history-mode path like `/projects` would + * collide with an endpoint. Everything after `#` is never sent to the server, + * so the static mount keeps working with zero server changes. + * + * Schema: + * #/ page with no project context + * #/p// page scoped to a project + * #/p//?branch= ... on a non-default branch + * #/p//?sel= ... with a page-owned selection + * + * `sel` is opaque to the router: the page that owns it decides what it means + * (a job id, `/`, ...). It is URL-encoded as a whole, so a + * multi-part selection joined with `/` survives the round trip. + * + * This module is deliberately React-free and side-effect-free so it can be + * unit tested directly (see `router.test.ts`); the wiring lives in `state.tsx`. + */ +import { PAGE_IDS, type PageId } from "./state"; + +/** Where an unknown / missing page lands. */ +export const DEFAULT_PAGE: PageId = "dashboard"; + +export interface RouteState { + page: PageId; + /** Project alias, or null for a page shown without project context. */ + project: string | null; + /** Non-default (dev) branch id, or null for production. */ + branchId: number | null; + /** Opaque, page-owned selection. */ + sel: string | null; +} + +/** + * `PAGE_IDS` is read inside the function bodies on purpose. `state.tsx` + * imports this module, so a module-level constant derived from it (a `Set`, + * say) would evaluate while `state.tsx` is still initializing and hit the + * temporal dead zone. A linear scan over ~two dozen ids costs nothing. + */ +function toPageId(raw: string | undefined): PageId { + if (!raw) return DEFAULT_PAGE; + return (PAGE_IDS as readonly string[]).includes(raw) ? (raw as PageId) : DEFAULT_PAGE; +} + +/** `decodeURIComponent` that returns the input verbatim on malformed escapes. */ +function safeDecode(raw: string): string { + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +/** + * A branch id is a positive integer. Anything else (a name, `0`, a float, + * garbage) is dropped rather than forwarded to the API as a bogus filter. + */ +function parseBranchId(raw: string | null): number | null { + if (!raw || !/^\d+$/.test(raw)) return null; + const n = Number(raw); + return Number.isSafeInteger(n) && n > 0 ? n : null; +} + +/** + * Parse a location hash into route state. Never throws: an empty, partial or + * malformed hash degrades to the dashboard with no project context. + * + * Accepts the value with or without the leading `#`, so both + * `window.location.hash` and a bare path can be passed. + */ +export function parseHash(hash: string): RouteState { + const withoutMarker = hash.startsWith("#") ? hash.slice(1) : hash; + const queryStart = withoutMarker.indexOf("?"); + const rawPath = queryStart === -1 ? withoutMarker : withoutMarker.slice(0, queryStart); + const rawQuery = queryStart === -1 ? "" : withoutMarker.slice(queryStart + 1); + + // Trim the delimiters at both ends only. Interior empties are KEPT so that + // `#/p//jobs` parses as "no project, page jobs" instead of shifting `jobs` + // into the project slot. + const trimmed = rawPath.replace(/^\/+/, "").replace(/\/+$/, ""); + const segments = trimmed === "" ? [] : trimmed.split("/"); + + let project: string | null = null; + let pageSegment: string | undefined; + if (segments[0] === "p") { + const alias = safeDecode(segments[1] ?? ""); + project = alias === "" ? null : alias; + pageSegment = segments[2] === undefined ? undefined : safeDecode(segments[2]); + } else { + pageSegment = segments[0] === undefined ? undefined : safeDecode(segments[0]); + } + + const params = new URLSearchParams(rawQuery); + const sel = params.get("sel"); + + return { + page: toPageId(pageSegment), + project, + branchId: parseBranchId(params.get("branch")), + sel: sel ? sel : null, + }; +} + +/** + * Render route state back into a location hash (leading `#` included). + * + * The query string is assembled by hand rather than via + * `URLSearchParams.toString()`: that encodes spaces as `+`, which reads badly + * in a link people paste to each other. `encodeURIComponent` emits `%20`, and + * `URLSearchParams` decodes both, so `parseHash(buildHash(x))` still round + * trips. + */ +export function buildHash(route: RouteState): string { + const page = route.page; + const path = route.project + ? `/p/${encodeURIComponent(route.project)}/${page}` + : `/${page}`; + + const params: string[] = []; + if (route.branchId != null) params.push(`branch=${route.branchId}`); + if (route.sel) params.push(`sel=${encodeURIComponent(route.sel)}`); + + return `#${path}${params.length ? `?${params.join("&")}` : ""}`; +} diff --git a/web/frontend/src/state.tsx b/web/frontend/src/state.tsx index 13d85467..f373a748 100644 --- a/web/frontend/src/state.tsx +++ b/web/frontend/src/state.tsx @@ -1,35 +1,49 @@ /** - * Lightweight global state via React Context. Holds the currently-selected - * project alias and active branch ID; pages read these to fan out queries. + * Lightweight global state via React Context. Holds the current page, the + * selected project alias, the active branch ID and the page-owned selection; + * pages read these to fan out queries. + * + * This state IS the URL: it is seeded from `window.location.hash` on the first + * render and mirrored back into the hash on every change, so any view can be + * shared as a link. See `router.ts` for the schema and the parse/build pair. */ -import { createContext, useContext, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; +import { buildHash, parseHash, type RouteState } from "./router"; -export type PageId = - | "dashboard" - | "projects" - | "configs" - | "storage" - | "stream" - | "jobs" - | "branches" - | "workspaces" - | "flows" - | "schedules" - | "lineage" - | "semantic-layer" - | "sharing" - | "data-apps" - | "components" - | "localai" - | "agents" - | "search" - | "encrypt" - | "org" - | "members" - | "tokens" - | "doctor" - | "changelog"; +/** + * Every navigable page. Single source of truth: the `PageId` union is derived + * from it, and the router validates the `` URL segment against it, so a + * page can never be routable-but-unknown or known-but-unroutable. + */ +export const PAGE_IDS = [ + "dashboard", + "projects", + "configs", + "storage", + "stream", + "jobs", + "branches", + "workspaces", + "flows", + "schedules", + "lineage", + "semantic-layer", + "sharing", + "data-apps", + "components", + "localai", + "agents", + "search", + "encrypt", + "org", + "members", + "tokens", + "doctor", + "changelog", +] as const; + +export type PageId = (typeof PAGE_IDS)[number]; interface UIState { page: PageId; @@ -38,6 +52,15 @@ interface UIState { setProject: (p: string | null) => void; branchId: number | null; setBranchId: (b: number | null) => void; + /** + * Opaque, page-owned selection mirrored into the URL as `?sel=`. The page + * that writes it defines its shape (a job id, `/`, ...); + * nothing outside that page may interpret it. Cleared automatically on any + * page / project / branch change -- a selected object from another context + * is meaningless. Pages consume it via `useHashSelection()`. + */ + sel: string | null; + setSel: (s: string | null) => void; manageToken: string | null; setManageToken: (t: string | null) => void; // Hand-off slot: the Dashboard hero "Ask " box drops a message here @@ -50,13 +73,97 @@ interface UIState { const UIStateContext = createContext(null); +/** Current location hash, or `""` outside a browser (tests, SSR). */ +function currentHash(): string { + return typeof window === "undefined" ? "" : window.location.hash; +} + +/** Replace the hash without touching the history stack. */ +function replaceHash(hash: string): void { + const { pathname, search } = window.location; + window.history.replaceState(null, "", `${pathname}${search}${hash}`); +} + export function UIStateProvider({ children }: { children: ReactNode }) { - const [page, setPage] = useState("dashboard"); - const [project, setProject] = useState(null); - const [branchId, setBranchId] = useState(null); + // Seeded from the URL so a shared link restores page + project + branch + + // selection on the very first render -- before any effect (notably the top + // bar's default-project pick) gets a chance to run. + const [initial] = useState(() => parseHash(currentHash())); + + const [page, setPageState] = useState(initial.page); + const [project, setProjectState] = useState(initial.project); + const [branchId, setBranchIdState] = useState(initial.branchId); + const [sel, setSel] = useState(initial.sel); const [manageToken, setManageToken] = useState(null); const [pendingLocalAiMessage, setPendingLocalAiMessage] = useState(null); + // Navigating to another page drops the previous page's selection: `sel` is + // page-owned, so carrying it across would hand one page another's cookie. + const setPage = useCallback((p: PageId) => { + setPageState(p); + setSel(null); + }, []); + + // Same reasoning across projects and branches: an object id resolved in one + // project (or branch) does not exist in the next one. + const setProject = useCallback((p: string | null) => { + setProjectState(p); + setSel(null); + }, []); + + const setBranchId = useCallback((b: number | null) => { + setBranchIdState(b); + setSel(null); + }, []); + + // Last hash WE wrote. A hashchange carrying exactly this value is our own + // write echoing back and must not be re-applied; anything else is a real + // navigation (Back/Forward, a hand-edited URL) and is parsed into state. + const lastWrittenRef = useRef(null); + const prevPageRef = useRef(initial.page); + + // State -> URL. + useEffect(() => { + if (typeof window === "undefined") return; + const pageChanged = page !== prevPageRef.current; + prevPageRef.current = page; + + const next = buildHash({ page, project, branchId, sel }); + if (next === window.location.hash) { + lastWrittenRef.current = next; + return; + } + lastWrittenRef.current = next; + if (pageChanged) { + // Assignment pushes a history entry, so Back walks PAGE history... + window.location.hash = next; + } else { + // ...while a project / branch / selection change only rewrites the + // current entry. Otherwise every row click would need its own Back. + replaceHash(next); + } + }, [page, project, branchId, sel]); + + // URL -> state (Back / Forward, hand-edited hash). + useEffect(() => { + if (typeof window === "undefined") return; + const onHashChange = () => { + const hash = window.location.hash; + if (hash === lastWrittenRef.current) return; + const route = parseHash(hash); + // Adopt the page silently: the write effect must treat this as "already + // in sync" and not push a duplicate history entry for it. + prevPageRef.current = route.page; + lastWrittenRef.current = hash; + setPageState(route.page); + setProjectState(route.project); + setBranchIdState(route.branchId); + setSel(route.sel); + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + return ( { setSelected(row); setSel(row.id); }} + * onClose={() => { setSelected(null); setSel(null); }} + * + * The value is opaque to everything outside the page that wrote it. Pages + * with a compound selection join the parts with `/` + * (e.g. `tables/in.c-main.orders`); the router URL-encodes the whole string, + * so the separator survives the round trip. + * + * The context clears it on every page / project / branch change, so a page + * only ever sees a selection that belongs to it. + */ +import { useUIState } from "./state"; + +export function useHashSelection(): [string | null, (s: string | null) => void] { + const { sel, setSel } = useUIState(); + return [sel, setSel]; +}