From 45d22cc5bfd7a85da64cd98f940718dfca1db803 Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Mon, 12 Oct 2026 14:59:04 +0000 Subject: [PATCH] feat(web): add Stovepipe request status UI Add queue request lists and request-ID detail pages using the summary and events returned by GetRequestHistoryByID. Provide a thin reference host, shared native styling, refresh, authentication, and local Compose startup. Builds on #842. --- Makefile | 19 +- web/BUILD.bazel | 7 + web/api/BUILD.bazel | 2 + web/api/package.json | 4 + .../gen/api/stovepipe/proto/stovepipe_pb.ts | 736 ++++++++++++++++++ web/pnpm-lock.yaml | 104 +++ web/pnpm-workspace.yaml | 1 + web/service/stovepipe/BUILD.bazel | 228 ++++++ web/service/stovepipe/README.md | 23 + web/service/stovepipe/docker-compose.yml | 26 + web/service/stovepipe/eslint.config.mjs | 7 + web/service/stovepipe/next-env.d.ts | 7 + web/service/stovepipe/next.config.ts | 24 + web/service/stovepipe/package.json | 37 + web/service/stovepipe/public/robots.txt | 2 + .../src/app/(protected)/[[...path]]/page.tsx | 3 + .../stovepipe/src/app/healthz/route.test.ts | 36 + .../stovepipe/src/app/healthz/route.ts | 29 + web/service/stovepipe/src/app/layout.tsx | 13 + web/service/stovepipe/src/app/not-found.tsx | 8 + .../stovepipe/src/app/unauthorized.tsx | 8 + web/service/stovepipe/src/next/authorize.ts | 15 + web/service/stovepipe/src/next/page.tsx | 34 + web/service/stovepipe/src/proxy.ts | 23 + web/service/stovepipe/src/server/auth.test.ts | 25 + web/service/stovepipe/src/server/auth.ts | 37 + .../stovepipe/src/server/config.test.ts | 37 + web/service/stovepipe/src/server/config.ts | 32 + web/service/stovepipe/src/server/host.ts | 28 + web/service/stovepipe/src/test/server-only.ts | 1 + web/service/stovepipe/tsconfig.json | 36 + web/service/stovepipe/vitest.config.ts | 18 + web/stovepipe/BUILD.bazel | 34 + web/stovepipe/README.md | 15 + web/stovepipe/controller/mapping.ts | 19 + web/stovepipe/controller/page.ts | 18 + web/stovepipe/controller/queue.ts | 24 + web/stovepipe/controller/request.ts | 30 + web/stovepipe/core/paths.ts | 15 + web/stovepipe/core/route.ts | 32 + web/stovepipe/entity/index.ts | 74 ++ web/stovepipe/eslint.config.mjs | 3 + web/stovepipe/extension/service/index.ts | 34 + web/stovepipe/index.ts | 8 + web/stovepipe/module.test.ts | 197 +++++ web/stovepipe/module.ts | 134 ++++ web/stovepipe/package.json | 52 ++ web/stovepipe/server.ts | 9 + web/stovepipe/test-setup.ts | 1 + web/stovepipe/tsconfig.build.json | 13 + web/stovepipe/tsconfig.json | 20 + web/stovepipe/view/app.test.tsx | 103 +++ web/stovepipe/view/app.tsx | 83 ++ web/stovepipe/view/components.tsx | 58 ++ web/stovepipe/view/pages.tsx | 140 ++++ web/stovepipe/view/refresh.test.tsx | 83 ++ web/stovepipe/view/refresh.tsx | 45 ++ web/stovepipe/view/styles.css | 12 + web/stovepipe/vitest.config.ts | 12 + web/test/package/BUILD.bazel | 12 + web/test/package/pack_contents_test.py | 11 +- web/test/package/react18/BUILD.bazel | 2 + web/test/package/react18/react18-runtime.mjs | 14 + 63 files changed, 2909 insertions(+), 8 deletions(-) create mode 100644 web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts create mode 100644 web/service/stovepipe/BUILD.bazel create mode 100644 web/service/stovepipe/README.md create mode 100644 web/service/stovepipe/docker-compose.yml create mode 100644 web/service/stovepipe/eslint.config.mjs create mode 100644 web/service/stovepipe/next-env.d.ts create mode 100644 web/service/stovepipe/next.config.ts create mode 100644 web/service/stovepipe/package.json create mode 100644 web/service/stovepipe/public/robots.txt create mode 100644 web/service/stovepipe/src/app/(protected)/[[...path]]/page.tsx create mode 100644 web/service/stovepipe/src/app/healthz/route.test.ts create mode 100644 web/service/stovepipe/src/app/healthz/route.ts create mode 100644 web/service/stovepipe/src/app/layout.tsx create mode 100644 web/service/stovepipe/src/app/not-found.tsx create mode 100644 web/service/stovepipe/src/app/unauthorized.tsx create mode 100644 web/service/stovepipe/src/next/authorize.ts create mode 100644 web/service/stovepipe/src/next/page.tsx create mode 100644 web/service/stovepipe/src/proxy.ts create mode 100644 web/service/stovepipe/src/server/auth.test.ts create mode 100644 web/service/stovepipe/src/server/auth.ts create mode 100644 web/service/stovepipe/src/server/config.test.ts create mode 100644 web/service/stovepipe/src/server/config.ts create mode 100644 web/service/stovepipe/src/server/host.ts create mode 100644 web/service/stovepipe/src/test/server-only.ts create mode 100644 web/service/stovepipe/tsconfig.json create mode 100644 web/service/stovepipe/vitest.config.ts create mode 100644 web/stovepipe/BUILD.bazel create mode 100644 web/stovepipe/README.md create mode 100644 web/stovepipe/controller/mapping.ts create mode 100644 web/stovepipe/controller/page.ts create mode 100644 web/stovepipe/controller/queue.ts create mode 100644 web/stovepipe/controller/request.ts create mode 100644 web/stovepipe/core/paths.ts create mode 100644 web/stovepipe/core/route.ts create mode 100644 web/stovepipe/entity/index.ts create mode 100644 web/stovepipe/eslint.config.mjs create mode 100644 web/stovepipe/extension/service/index.ts create mode 100644 web/stovepipe/index.ts create mode 100644 web/stovepipe/module.test.ts create mode 100644 web/stovepipe/module.ts create mode 100644 web/stovepipe/package.json create mode 100644 web/stovepipe/server.ts create mode 100644 web/stovepipe/test-setup.ts create mode 100644 web/stovepipe/tsconfig.build.json create mode 100644 web/stovepipe/tsconfig.json create mode 100644 web/stovepipe/view/app.test.tsx create mode 100644 web/stovepipe/view/app.tsx create mode 100644 web/stovepipe/view/components.tsx create mode 100644 web/stovepipe/view/pages.tsx create mode 100644 web/stovepipe/view/refresh.test.tsx create mode 100644 web/stovepipe/view/refresh.tsx create mode 100644 web/stovepipe/view/styles.css create mode 100644 web/stovepipe/vitest.config.ts diff --git a/Makefile b/Makefile index 418f46ff8..b30b91d2e 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ SUBMITQUEUE_LOCAL_PROJECT = submitqueue # Stovepipe compose file (single Ping-only service) STOVEPIPE_COMPOSE_FILE = $(abspath service/stovepipe/docker-compose.yml) +STOVEPIPE_WEB_COMPOSE_FILE = $(abspath web/service/stovepipe/docker-compose.yml) STOVEPIPE_DEBUG_COMPOSE_FILE = $(abspath service/stovepipe/docker-compose.debug.yml) # Fixed project name for local manual testing (tests use unique random names) @@ -568,7 +569,7 @@ local-submitqueue-stop: ## Stop the SubmitQueue stack (keeps PROVIDER=git's sand local-stop: ## Stop every local stack — SubmitQueue, Stovepipe, and Runway @echo "Stopping all services..." @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down - @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) down + @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_WEB_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) down @$(COMPOSE) -f $(RUNWAY_COMPOSE_FILE) -p $(RUNWAY_LOCAL_PROJECT) down @echo "Services stopped. Anonymous database volumes are not reused on the next start." @@ -586,7 +587,7 @@ local-stovepipe-debug-start: build-stovepipe-linux-debug ## Start Stovepipe unde @echo "Delve: 127.0.0.1:2345 — run launch config \"Debug: attach (dlv in docker)\"" local-stovepipe-logs: ## View logs from the running Stovepipe service - @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) logs -f + @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_WEB_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) logs -f local-stovepipe-start: build-stovepipe-linux ## Start Stovepipe service (gRPC service + MySQL storage + MySQL queue) @echo "Starting Stovepipe service with compose..." @@ -602,9 +603,15 @@ local-stovepipe-start: build-stovepipe-linux ## Start Stovepipe service (gRPC se local-stovepipe-stop: ## Stop the Stovepipe service @echo "Stopping Stovepipe service..." - @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) down + @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_WEB_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) down @echo "Stovepipe service stopped." +local-stovepipe-web-start: local-stovepipe-start ## Start Stovepipe and its web UI on free ports + @$(BAZEL) run //web/service/stovepipe:image_load + @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_WEB_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) up -d --no-build --wait web-service + @echo "Web UX: http://localhost:$$($(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -f $(STOVEPIPE_WEB_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) port web-service 3000 | sed 's/.*://')" + @echo "Web username: test; password: $${STOVEPIPE_WEB_TOKEN:-test}" + # go generate does not walk the module; every tree with a //go:generate directive is listed here. GO_GENERATE_PACKAGES := \ ./platform/consumer/... \ @@ -698,12 +705,12 @@ tidy-go: ## Run go mod tidy @echo "Running go mod tidy..." @$(BAZEL) run @rules_go//go -- mod tidy -e -web-build: ## Build the web packages and deployable Next.js image with Bazel - @$(BAZEL) build //web/submitqueue:pkg //web/service/submitqueue:image +web-build: ## Build the web packages and deployable Next.js images with Bazel + @$(BAZEL) build //web/stovepipe:pkg //web/submitqueue:pkg //web/service/submitqueue:image //web/service/stovepipe:image web-check: ## Run web generation, packaging, lint, type, unit, and image checks with Bazel @$(BAZEL) test //web:check --test_output=errors - @$(BAZEL) build //web/service/submitqueue:image + @$(BAZEL) build //web/service/submitqueue:image //web/service/stovepipe:image web-e2e-test: ## Run the Bazel-managed real-stack Playwright and axe web test @$(BAZEL) test //web/test/e2e:web_test --test_output=errors diff --git a/web/BUILD.bazel b/web/BUILD.bazel index e9903358a..dd62ef985 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -26,7 +26,14 @@ test_suite( "//web/service/shared:lint", "//web/service/shared:test", "//web/service/shared:typecheck_typecheck_test", + "//web/service/stovepipe:lint", + "//web/service/stovepipe:test", + "//web/service/stovepipe:typecheck_typecheck_test", + "//web/stovepipe:lint", + "//web/stovepipe:test", + "//web/stovepipe:typecheck_typecheck_test", "//web/submitqueue:lint", + "//web/test/package:stovepipe_pack_contents_test", ":e2e_typecheck_typecheck_test", ], ) diff --git a/web/api/BUILD.bazel b/web/api/BUILD.bazel index 1cd5bd4e6..f149a6e3d 100644 --- a/web/api/BUILD.bazel +++ b/web/api/BUILD.bazel @@ -12,6 +12,7 @@ load("//web/tool:generate_api.bzl", "generate_web_api") package(default_visibility = [ "//web:__pkg__", "//web/service/submitqueue:__pkg__", + "//web/service/stovepipe:__pkg__", "//web/test/e2e:__pkg__", ]) @@ -29,6 +30,7 @@ generate_web_api( "//api/base/change/proto:change.proto", "//api/base/mergestrategy/proto:mergestrategy.proto", "//api/submitqueue/gateway/proto:gateway.proto", + "//api/stovepipe/proto:stovepipe.proto", ], ) diff --git a/web/api/package.json b/web/api/package.json index 02e08eee1..b84fc5a78 100644 --- a/web/api/package.json +++ b/web/api/package.json @@ -18,6 +18,10 @@ "./submitqueue/gateway": { "types": "./dist/api/submitqueue/gateway/proto/gateway_pb.d.ts", "import": "./dist/api/submitqueue/gateway/proto/gateway_pb.js" + }, + "./stovepipe": { + "types": "./dist/api/stovepipe/proto/stovepipe_pb.d.ts", + "import": "./dist/api/stovepipe/proto/stovepipe_pb.js" } }, "scripts": { diff --git a/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts b/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts new file mode 100644 index 000000000..4897473e4 --- /dev/null +++ b/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts @@ -0,0 +1,736 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.15.0 with parameter "target=ts,import_extension=js" +// @generated from file api/stovepipe/proto/stovepipe.proto (package uber.submitqueue.stovepipe, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file api/stovepipe/proto/stovepipe.proto. + */ +export const file_api_stovepipe_proto_stovepipe: GenFile = /*@__PURE__*/ + fileDesc("CiNhcGkvc3RvdmVwaXBlL3Byb3RvL3N0b3ZlcGlwZS5wcm90bxIadWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUiHgoLUGluZ1JlcXVlc3QSDwoHbWVzc2FnZRgBIAEoCSJaCgxQaW5nUmVzcG9uc2USDwoHbWVzc2FnZRgBIAEoCRIUCgxzZXJ2aWNlX25hbWUYAiABKAkSEQoJdGltZXN0YW1wGAMgASgDEhAKCGhvc3RuYW1lGAQgASgJIh4KDUluZ2VzdFJlcXVlc3QSDQoFcXVldWUYASABKAkiHAoOSW5nZXN0UmVzcG9uc2USCgoCaWQYASABKAkiQQocR2V0UmVxdWVzdEhpc3RvcnlCeUlEUmVxdWVzdBINCgVxdWV1ZRgBIAEoCRISCgpyZXF1ZXN0X2lkGAIgASgJIjsKHUdldFJlcXVlc3RIaXN0b3J5QnlVUklSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEgsKA3VyaRgCIAEoCSKGAQoMSGlzdG9yeUV2ZW50EhAKCGV2ZW50X2lkGAEgASgJEhQKDHRpbWVzdGFtcF9tcxgCIAEoAxIXCg1yZXF1ZXN0X3N0YXRlGAMgASgJSAASDwoFZXZlbnQYBCABKAlIABIWCg5vdXRjb21lX3JlYXNvbhgFIAEoCUIMCgpvY2N1cnJlbmNlIqMBCg5SZXF1ZXN0SGlzdG9yeRISCgpyZXF1ZXN0X2lkGAEgASgJEjgKBmV2ZW50cxgCIAMoCzIoLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkhpc3RvcnlFdmVudBJDCg9yZXF1ZXN0X3N1bW1hcnkYAyABKAsyKi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5SZXF1ZXN0U3VtbWFyeSKeAQodR2V0UmVxdWVzdEhpc3RvcnlCeUlEUmVzcG9uc2USOAoGZXZlbnRzGAEgAygLMigudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuSGlzdG9yeUV2ZW50EkMKD3JlcXVlc3Rfc3VtbWFyeRgCIAEoCzIqLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlJlcXVlc3RTdW1tYXJ5Il8KHkdldFJlcXVlc3RIaXN0b3J5QnlVUklSZXNwb25zZRI9CgloaXN0b3JpZXMYASADKAsyKi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5SZXF1ZXN0SGlzdG9yeSJ6ChxHZXRQcm9qZWN0U3RhdHVzQnlVUklSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEhIKCmNoYW5nZV91cmkYAiABKAkSEAoIcHJvamVjdHMYAyADKAkSEQoJcGFnZV9zaXplGAQgASgFEhIKCnBhZ2VfdG9rZW4YBSABKAkiSQoRUHJvamVjdFZhbGlkYXRpb24SDwoHcHJvamVjdBgBIAEoCRIZCg9icmVha2FnZV9kZWdyZWUYAiABKAFIAEIICgZyZXN1bHQizQIKHUdldFByb2plY3RTdGF0dXNCeVVSSVJlc3BvbnNlEhIKCnJlcXVlc3RfaWQYASABKAkSDQoFcXVldWUYAiABKAkSEgoKY2hhbmdlX3VyaRgDIAEoCRIQCghiYXNlX3VyaRgEIAEoCRIVCg1yZXF1ZXN0X3N0YXRlGAUgASgJEhUKDXVwZGF0ZWRfYXRfbXMYCiABKAMSJAoacmVwb3NpdG9yeV9icmVha2FnZV9kZWdyZWUYBiABKAFIABIgChhwcm9qZWN0X3Jlc3VsdHNfY29tcGxldGUYByABKAgSPwoIcHJvamVjdHMYCCADKAsyLS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5Qcm9qZWN0VmFsaWRhdGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YCSABKAlCEwoRcmVwb3NpdG9yeV9yZXN1bHQivQEKDlJlcXVlc3RTdW1tYXJ5EhIKCnJlcXVlc3RfaWQYASABKAkSDQoFcXVldWUYAiABKAkSEgoKY2hhbmdlX3VyaRgDIAEoCRIQCghiYXNlX3VyaRgEIAEoCRIVCg1yZXF1ZXN0X3N0YXRlGAUgASgJEhsKE3N0YXRlX3VwZGF0ZWRfYXRfbXMYBiABKAMSFgoOYWNjZXB0ZWRfYXRfbXMYByABKAMSFgoOb3V0Y29tZV9yZWFzb24YCCABKAkitAEKC0xpc3RSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEiEKF2FjY2VwdGVkX2F0X29yX2FmdGVyX21zGAIgASgDSAASHAoSYWNjZXB0ZWRfYmVmb3JlX21zGAMgASgDSAESEQoJcGFnZV9zaXplGAQgASgFEhIKCnBhZ2VfdG9rZW4YBSABKAlCFgoUYWNjZXB0ZWRfbG93ZXJfYm91bmRCFgoUYWNjZXB0ZWRfdXBwZXJfYm91bmQiZQoMTGlzdFJlc3BvbnNlEjwKCHJlcXVlc3RzGAEgAygLMioudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUmVxdWVzdFN1bW1hcnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJMt4FCglTdG92ZXBpcGUSWwoEUGluZxInLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLlBpbmdSZXF1ZXN0GigudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuUGluZ1Jlc3BvbnNlIgASYQoGSW5nZXN0EikudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuSW5nZXN0UmVxdWVzdBoqLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkluZ2VzdFJlc3BvbnNlIgASjgEKFUdldFJlcXVlc3RIaXN0b3J5QnlJRBI4LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFJlcXVlc3RIaXN0b3J5QnlJRFJlcXVlc3QaOS51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5HZXRSZXF1ZXN0SGlzdG9yeUJ5SURSZXNwb25zZSIAEpEBChZHZXRSZXF1ZXN0SGlzdG9yeUJ5VVJJEjkudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UmVxdWVzdEhpc3RvcnlCeVVSSVJlcXVlc3QaOi51YmVyLnN1Ym1pdHF1ZXVlLnN0b3ZlcGlwZS5HZXRSZXF1ZXN0SGlzdG9yeUJ5VVJJUmVzcG9uc2UiABKOAQoVR2V0UHJvamVjdFN0YXR1c0J5VVJJEjgudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuR2V0UHJvamVjdFN0YXR1c0J5VVJJUmVxdWVzdBo5LnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkdldFByb2plY3RTdGF0dXNCeVVSSVJlc3BvbnNlIgASWwoETGlzdBInLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlLkxpc3RSZXF1ZXN0GigudWJlci5zdWJtaXRxdWV1ZS5zdG92ZXBpcGUuTGlzdFJlc3BvbnNlIgBCZQoeY29tLnViZXIuc3VibWl0cXVldWUuc3RvdmVwaXBlQg5TdG92ZXBpcGVQcm90b1ABWjFnaXRodWIuY29tL3ViZXIvc3VibWl0cXVldWUvYXBpL3N0b3ZlcGlwZS9wcm90b3BiYgZwcm90bzM"); + +/** + * PingRequest is the request for the Ping method + * + * @generated from message uber.submitqueue.stovepipe.PingRequest + */ +export type PingRequest = Message<"uber.submitqueue.stovepipe.PingRequest"> & { + /** + * Optional message to include in the ping + * + * @generated from field: string message = 1; + */ + message: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.PingRequest. + * Use `create(PingRequestSchema)` to create a new message. + */ +export const PingRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 0); + +/** + * PingResponse is the response for the Ping method + * + * @generated from message uber.submitqueue.stovepipe.PingResponse + */ +export type PingResponse = Message<"uber.submitqueue.stovepipe.PingResponse"> & { + /** + * The response message + * + * @generated from field: string message = 1; + */ + message: string; + + /** + * The service name that handled the request + * + * @generated from field: string service_name = 2; + */ + serviceName: string; + + /** + * Timestamp of when the ping was received + * + * @generated from field: int64 timestamp = 3; + */ + timestamp: bigint; + + /** + * Hostname of the server that handled the request + * + * @generated from field: string hostname = 4; + */ + hostname: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.PingResponse. + * Use `create(PingResponseSchema)` to create a new message. + */ +export const PingResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 1); + +/** + * IngestRequest is the request for the Ingest method. The poller reports that a queue + * (a named repo+ref) has a new commit to validate. + * + * @generated from message uber.submitqueue.stovepipe.IngestRequest + */ +export type IngestRequest = Message<"uber.submitqueue.stovepipe.IngestRequest"> & { + /** + * Logical queue name to validate (e.g. "monorepo/main"). Together with the minted + * request ID it identifies the request and is the stable handle for the repo+ref. + * + * @generated from field: string queue = 1; + */ + queue: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.IngestRequest. + * Use `create(IngestRequestSchema)` to create a new message. + */ +export const IngestRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 2); + +/** + * IngestResponse is the response for the Ingest method. + * + * @generated from message uber.submitqueue.stovepipe.IngestResponse + */ +export type IngestResponse = Message<"uber.submitqueue.stovepipe.IngestResponse"> & { + /** + * Canonical decimal request ID, unique within the selected queue. + * + * @generated from field: string id = 1; + */ + id: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.IngestResponse. + * Use `create(IngestResponseSchema)` to create a new message. + */ +export const IngestResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 3); + +/** + * GetRequestHistoryByIDRequest selects one request history by the ID returned from Ingest. + * + * @generated from message uber.submitqueue.stovepipe.GetRequestHistoryByIDRequest + */ +export type GetRequestHistoryByIDRequest = Message<"uber.submitqueue.stovepipe.GetRequestHistoryByIDRequest"> & { + /** + * Logical queue containing the request. + * + * @generated from field: string queue = 1; + */ + queue: string; + + /** + * Canonical decimal request identifier returned from Ingest. + * + * @generated from field: string request_id = 2; + */ + requestId: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.GetRequestHistoryByIDRequest. + * Use `create(GetRequestHistoryByIDRequestSchema)` to create a new message. + */ +export const GetRequestHistoryByIDRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 4); + +/** + * GetRequestHistoryByURIRequest selects retained request histories for an exact commit URI. + * + * @generated from message uber.submitqueue.stovepipe.GetRequestHistoryByURIRequest + */ +export type GetRequestHistoryByURIRequest = Message<"uber.submitqueue.stovepipe.GetRequestHistoryByURIRequest"> & { + /** + * Logical queue containing the request. + * + * @generated from field: string queue = 1; + */ + queue: string; + + /** + * Exact VCS-agnostic commit URI whose request histories are selected. + * + * @generated from field: string uri = 2; + */ + uri: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.GetRequestHistoryByURIRequest. + * Use `create(GetRequestHistoryByURIRequestSchema)` to create a new message. + */ +export const GetRequestHistoryByURIRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 5); + +/** + * HistoryEvent is one retained request state or lifecycle event. + * + * @generated from message uber.submitqueue.stovepipe.HistoryEvent + */ +export type HistoryEvent = Message<"uber.submitqueue.stovepipe.HistoryEvent"> & { + /** + * Opaque stable identity of this logical occurrence within the request. + * + * @generated from field: string event_id = 1; + */ + eventId: string; + + /** + * Occurrence time in Unix milliseconds. + * + * @generated from field: int64 timestamp_ms = 2; + */ + timestampMs: bigint; + + /** + * Exactly one occurrence kind is populated. + * + * @generated from oneof uber.submitqueue.stovepipe.HistoryEvent.occurrence + */ + occurrence: { + /** + * Durable request state reached by the request. + * + * @generated from field: string request_state = 3; + */ + value: string; + case: "requestState"; + } | { + /** + * Event that occurred without changing the request state. + * + * @generated from field: string event = 4; + */ + value: string; + case: "event"; + } | { case: undefined; value?: undefined }; + + /** + * Stable domain reason for a terminal request outcome. Empty otherwise. + * + * @generated from field: string outcome_reason = 5; + */ + outcomeReason: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.HistoryEvent. + * Use `create(HistoryEventSchema)` to create a new message. + */ +export const HistoryEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 6); + +/** + * RequestHistory identifies one request and contains its ordered events and current summary. + * + * @generated from message uber.submitqueue.stovepipe.RequestHistory + */ +export type RequestHistory = Message<"uber.submitqueue.stovepipe.RequestHistory"> & { + /** + * Queue-scoped decimal request identifier. + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * Events ordered by occurrence time and stable event identity. + * + * @generated from field: repeated uber.submitqueue.stovepipe.HistoryEvent events = 2; + */ + events: HistoryEvent[]; + + /** + * Current materialized lifecycle summary. Always present in successful responses. + * + * @generated from field: uber.submitqueue.stovepipe.RequestSummary request_summary = 3; + */ + requestSummary?: RequestSummary | undefined; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.RequestHistory. + * Use `create(RequestHistorySchema)` to create a new message. + */ +export const RequestHistorySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 7); + +/** + * GetRequestHistoryByIDResponse contains retained events and the current summary for one request. + * + * @generated from message uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse + */ +export type GetRequestHistoryByIDResponse = Message<"uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse"> & { + /** + * Events ordered by occurrence time and stable event identity. + * + * @generated from field: repeated uber.submitqueue.stovepipe.HistoryEvent events = 1; + */ + events: HistoryEvent[]; + + /** + * Current materialized lifecycle summary. Always present in successful responses. + * + * @generated from field: uber.submitqueue.stovepipe.RequestSummary request_summary = 2; + */ + requestSummary?: RequestSummary | undefined; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.GetRequestHistoryByIDResponse. + * Use `create(GetRequestHistoryByIDResponseSchema)` to create a new message. + */ +export const GetRequestHistoryByIDResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 8); + +/** + * GetRequestHistoryByURIResponse contains all retained request histories for an exact URI. + * + * @generated from message uber.submitqueue.stovepipe.GetRequestHistoryByURIResponse + */ +export type GetRequestHistoryByURIResponse = Message<"uber.submitqueue.stovepipe.GetRequestHistoryByURIResponse"> & { + /** + * Histories ordered by numeric request-ID counter, then request ID. + * + * @generated from field: repeated uber.submitqueue.stovepipe.RequestHistory histories = 1; + */ + histories: RequestHistory[]; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.GetRequestHistoryByURIResponse. + * Use `create(GetRequestHistoryByURIResponseSchema)` to create a new message. + */ +export const GetRequestHistoryByURIResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 9); + +/** + * GetProjectStatusByURIRequest selects the validation status for an exact commit URI. + * + * @generated from message uber.submitqueue.stovepipe.GetProjectStatusByURIRequest + */ +export type GetProjectStatusByURIRequest = Message<"uber.submitqueue.stovepipe.GetProjectStatusByURIRequest"> & { + /** + * Logical queue containing the validation request. + * + * @generated from field: string queue = 1; + */ + queue: string; + + /** + * Exact VCS-agnostic URI of the commit under validation. + * + * @generated from field: string change_uri = 2; + */ + changeUri: string; + + /** + * Required consumer-defined project IDs whose recorded results are requested. + * + * @generated from field: repeated string projects = 3; + */ + projects: string[]; + + /** + * Maximum requested projects to return in one page. Zero selects the server default. + * + * @generated from field: int32 page_size = 4; + */ + pageSize: number; + + /** + * Opaque continuation token for requested-project result pagination. + * + * @generated from field: string page_token = 5; + */ + pageToken: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.GetProjectStatusByURIRequest. + * Use `create(GetProjectStatusByURIRequestSchema)` to create a new message. + */ +export const GetProjectStatusByURIRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 10); + +/** + * ProjectValidation contains one reported project and its recorded result, if any. + * + * @generated from message uber.submitqueue.stovepipe.ProjectValidation + */ +export type ProjectValidation = Message<"uber.submitqueue.stovepipe.ProjectValidation"> & { + /** + * Stable consumer-defined project identifier. + * + * @generated from field: string project = 1; + */ + project: string; + + /** + * Breakage degree on [0.0, 1.0]. Zero is green; absence means no result is recorded yet. + * + * @generated from oneof uber.submitqueue.stovepipe.ProjectValidation.result + */ + result: { + /** + * @generated from field: double breakage_degree = 2; + */ + value: number; + case: "breakageDegree"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.ProjectValidation. + * Use `create(ProjectValidationSchema)` to create a new message. + */ +export const ProjectValidationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 11); + +/** + * GetProjectStatusByURIResponse contains the selected validation's current projection. + * + * @generated from message uber.submitqueue.stovepipe.GetProjectStatusByURIResponse + */ +export type GetProjectStatusByURIResponse = Message<"uber.submitqueue.stovepipe.GetProjectStatusByURIResponse"> & { + /** + * Queue-scoped decimal identifier of the validation request. + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * Logical queue containing the validation request. + * + * @generated from field: string queue = 2; + */ + queue: string; + + /** + * VCS-agnostic URI of the commit under validation. + * + * @generated from field: string change_uri = 3; + */ + changeUri: string; + + /** + * Baseline URI for incremental validation. Empty for a full build. + * + * @generated from field: string base_uri = 4; + */ + baseUri: string; + + /** + * Stable public lifecycle state of the validation request. + * + * @generated from field: string request_state = 5; + */ + requestState: string; + + /** + * Unix millisecond timestamp of the newest durable lifecycle or result record. + * + * @generated from field: int64 updated_at_ms = 10; + */ + updatedAtMs: bigint; + + /** + * Whole-repository breakage degree. Absent until the result is durable. + * + * @generated from oneof uber.submitqueue.stovepipe.GetProjectStatusByURIResponse.repository_result + */ + repositoryResult: { + /** + * @generated from field: double repository_breakage_degree = 6; + */ + value: number; + case: "repositoryBreakageDegree"; + } | { case: undefined; value?: undefined }; + + /** + * True after the implementation has durably finished producing relevant project results. + * + * @generated from field: bool project_results_complete = 7; + */ + projectResultsComplete: boolean; + + /** + * Recorded results for requested projects. + * + * @generated from field: repeated uber.submitqueue.stovepipe.ProjectValidation projects = 8; + */ + projects: ProjectValidation[]; + + /** + * Opaque continuation token for another requested-project result page. Empty on the final page. + * + * @generated from field: string next_page_token = 9; + */ + nextPageToken: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.GetProjectStatusByURIResponse. + * Use `create(GetProjectStatusByURIResponseSchema)` to create a new message. + */ +export const GetProjectStatusByURIResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 12); + +/** + * RequestSummary contains one request's current materialized lifecycle state. + * + * @generated from message uber.submitqueue.stovepipe.RequestSummary + */ +export type RequestSummary = Message<"uber.submitqueue.stovepipe.RequestSummary"> & { + /** + * Opaque identifier returned from Ingest. + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * Queue containing the request. + * + * @generated from field: string queue = 2; + */ + queue: string; + + /** + * Exact VCS-agnostic URI of the ingested commit. + * + * @generated from field: string change_uri = 3; + */ + changeUri: string; + + /** + * Selected baseline URI. Empty before selection or for a full build. + * + * @generated from field: string base_uri = 4; + */ + baseUri: string; + + /** + * Stable public lifecycle state; clients tolerate future values. + * + * @generated from field: string request_state = 5; + */ + requestState: string; + + /** + * Unix millisecond timestamp of the represented state entry. + * + * @generated from field: int64 state_updated_at_ms = 6; + */ + stateUpdatedAtMs: bigint; + + /** + * Original retained accepted-log timestamp in Unix milliseconds; positive when known, zero if unknown. + * + * @generated from field: int64 accepted_at_ms = 7; + */ + acceptedAtMs: bigint; + + /** + * Stable reason for the represented state. Empty when unavailable or inapplicable. + * + * @generated from field: string outcome_reason = 8; + */ + outcomeReason: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.RequestSummary. + * Use `create(RequestSummarySchema)` to create a new message. + */ +export const RequestSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 13); + +/** + * ListRequest selects a page of retained request summaries for one queue. + * + * @generated from message uber.submitqueue.stovepipe.ListRequest + */ +export type ListRequest = Message<"uber.submitqueue.stovepipe.ListRequest"> & { + /** + * Required configured queue. + * + * @generated from field: string queue = 1; + */ + queue: string; + + /** + * @generated from oneof uber.submitqueue.stovepipe.ListRequest.accepted_lower_bound + */ + acceptedLowerBound: { + /** + * Inclusive Unix millisecond bound; omitted defaults to zero. + * + * @generated from field: int64 accepted_at_or_after_ms = 2; + */ + value: bigint; + case: "acceptedAtOrAfterMs"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof uber.submitqueue.stovepipe.ListRequest.accepted_upper_bound + */ + acceptedUpperBound: { + /** + * Exclusive Unix millisecond bound; omitted defaults to server now. + * + * @generated from field: int64 accepted_before_ms = 3; + */ + value: bigint; + case: "acceptedBeforeMs"; + } | { case: undefined; value?: undefined }; + + /** + * Zero selects 50; nonzero values must be between 1 and 200. + * + * @generated from field: int32 page_size = 4; + */ + pageSize: number; + + /** + * Opaque continuation token bound to the queue and resolved time bounds. + * + * @generated from field: string page_token = 5; + */ + pageToken: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.ListRequest. + * Use `create(ListRequestSchema)` to create a new message. + */ +export const ListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 14); + +/** + * ListResponse contains a page in acceptance-time order, not a snapshot across pages. + * + * @generated from message uber.submitqueue.stovepipe.ListResponse + */ +export type ListResponse = Message<"uber.submitqueue.stovepipe.ListResponse"> & { + /** + * Current summaries ordered by acceptance time descending, then bytewise request ID descending. + * Requests with accepted_at_ms = 0 (unknown) are excluded. + * + * @generated from field: repeated uber.submitqueue.stovepipe.RequestSummary requests = 1; + */ + requests: RequestSummary[]; + + /** + * Opaque continuation token; empty when no further row was observed. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message uber.submitqueue.stovepipe.ListResponse. + * Use `create(ListResponseSchema)` to create a new message. + */ +export const ListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_stovepipe_proto_stovepipe, 15); + +/** + * Stovepipe provides the Stovepipe API. + * + * @generated from service uber.submitqueue.stovepipe.Stovepipe + */ +export const Stovepipe: GenService<{ + /** + * Ping returns a response indicating the service is alive + * + * @generated from rpc uber.submitqueue.stovepipe.Stovepipe.Ping + */ + ping: { + methodKind: "unary"; + input: typeof PingRequestSchema; + output: typeof PingResponseSchema; + }, + /** + * Ingest admits a queue's newly observed commit into the validation pipeline and returns + * the minted request ID. The caller hands off asynchronously; validation happens later. + * + * @generated from rpc uber.submitqueue.stovepipe.Stovepipe.Ingest + */ + ingest: { + methodKind: "unary"; + input: typeof IngestRequestSchema; + output: typeof IngestResponseSchema; + }, + /** + * GetRequestHistoryByID returns retained history for one request ID. + * + * @generated from rpc uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByID + */ + getRequestHistoryByID: { + methodKind: "unary"; + input: typeof GetRequestHistoryByIDRequestSchema; + output: typeof GetRequestHistoryByIDResponseSchema; + }, + /** + * GetRequestHistoryByURI returns retained histories for an exact commit URI. + * + * @generated from rpc uber.submitqueue.stovepipe.Stovepipe.GetRequestHistoryByURI + */ + getRequestHistoryByURI: { + methodKind: "unary"; + input: typeof GetRequestHistoryByURIRequestSchema; + output: typeof GetRequestHistoryByURIResponseSchema; + }, + /** + * GetProjectStatusByURI returns the current validation status for an exact commit URI. + * + * @generated from rpc uber.submitqueue.stovepipe.Stovepipe.GetProjectStatusByURI + */ + getProjectStatusByURI: { + methodKind: "unary"; + input: typeof GetProjectStatusByURIRequestSchema; + output: typeof GetProjectStatusByURIResponseSchema; + }, + /** + * List returns a queue-scoped page of current request summaries. + * + * @generated from rpc uber.submitqueue.stovepipe.Stovepipe.List + */ + list: { + methodKind: "unary"; + input: typeof ListRequestSchema; + output: typeof ListResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_api_stovepipe_proto_stovepipe, 0); + diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 843eecab6..f38c58742 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -151,6 +151,67 @@ importers: specifier: 4.0.7 version: 4.0.7(@types/node@22.18.6)(jsdom@27.4.0) + service/stovepipe: + dependencies: + '@bufbuild/protobuf': + specifier: 2.15.0 + version: 2.15.0 + '@connectrpc/connect': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-node': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0)) + '@opentelemetry/api': + specifier: 1.9.0 + version: 1.9.0 + '@submitqueue/api': + specifier: 0.1.0 + version: link:../../api + '@submitqueue/web-service-shared': + specifier: 0.0.0 + version: link:../shared + '@submitqueue/web-stovepipe': + specifier: 0.0.0 + version: link:../../stovepipe + next: + specifier: '>=16.3.0 <17' + version: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(@types/node@22.18.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pino: + specifier: ^10.4.0 + version: 10.4.0 + react: + specifier: ^19.2.0 + version: 19.3.0 + react-dom: + specifier: ^19.2.0 + version: 19.3.0(react@19.3.0) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + devDependencies: + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + '@types/react': + specifier: ^19.1.16 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.9 + version: 19.3.0(@types/react@19.3.0) + eslint: + specifier: ^9.36.0 + version: 9.39.5 + eslint-config-next: + specifier: '>=16.3.0 <17' + version: 16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.7 + version: 4.0.7(@types/node@22.18.6)(jsdom@27.4.0) + service/submitqueue: dependencies: '@bufbuild/protobuf': @@ -218,6 +279,49 @@ importers: specifier: 4.0.7 version: 4.0.7(@types/node@22.18.6)(jsdom@27.4.0) + stovepipe: + dependencies: + '@submitqueue/web-platform': + specifier: 0.0.0 + version: link:../platform + devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.10.0(@testing-library/dom@10.4.2) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + '@types/react': + specifier: ^19.1.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.3.0(@types/react@19.3.0) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + pino: + specifier: ^10.4.0 + version: 10.4.0 + react: + specifier: ^19.1.0 + version: 19.3.0 + react-dom: + specifier: ^19.1.0 + version: 19.3.0(react@19.3.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: 4.0.7 + version: 4.0.7(@types/node@22.18.6)(jsdom@26.1.0) + submitqueue: dependencies: '@submitqueue/web-platform': diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 848251e06..77d66261e 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - api - service/* - submitqueue + - stovepipe - test/package/react18 hoist: false diff --git a/web/service/stovepipe/BUILD.bazel b/web/service/stovepipe/BUILD.bazel new file mode 100644 index 000000000..c4fd51774 --- /dev/null +++ b/web/service/stovepipe/BUILD.bazel @@ -0,0 +1,228 @@ +load("@aspect_rules_js//contrib/nextjs:defs.bzl", "nextjs_standalone_build", "nextjs_standalone_server") +load("@aspect_rules_js//js:defs.bzl", "js_image_layer", "js_run_devserver") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/service/stovepipe:eslint/package_json.bzl", eslint = "bin") +load("@npm//web/service/stovepipe:next/package_json.bzl", next = "bin") +load("@npm//web/service/stovepipe:vitest/package_json.bzl", vitest = "bin") +load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load") + +package(default_visibility = ["//web:__subpackages__"]) + +npm_link_all_packages(name = "node_modules") + +ts_config( + name = "tsconfig", + src = "tsconfig.json", +) + +ts_project( + name = "typecheck", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]) + [ + "next.config.ts", + "next-env.d.ts", + "vitest.config.ts", + ], + declaration = False, + incremental = True, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules/@bufbuild/protobuf", + ":node_modules/@connectrpc/connect", + ":node_modules/@connectrpc/connect-node", + ":node_modules/@opentelemetry/api", + ":node_modules/@submitqueue/api", + ":node_modules/@submitqueue/web-service-shared", + ":node_modules/@submitqueue/web-stovepipe", + ":node_modules/@types/node", + ":node_modules/@types/react", + ":node_modules/@types/react-dom", + ":node_modules/next", + ":node_modules/pino", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/server-only", + ":node_modules/vitest", + ], +) + +eslint.eslint_test( + name = "lint", + args = ["."], + chdir = package_name(), + data = [ + "eslint.config.mjs", + "next.config.ts", + "next-env.d.ts", + "package.json", + "tsconfig.json", + "vitest.config.ts", + ":node_modules", + ":node_modules/@submitqueue/web-service-shared", + ":node_modules/eslint", + ":node_modules/eslint-config-next", + "//web:node_modules/next", + "//web/platform:lib", + "//web/service/shared:lib", + ] + glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]), +) + +vitest.vitest_test( + name = "test", + args = [ + "run", + "--config", + "vitest.config.ts", + ], + chdir = package_name(), + data = [ + "vitest.config.ts", + ":node_modules", + ":node_modules/@submitqueue/web-service-shared", + ":node_modules/vitest", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@connectrpc/connect-node", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/next", + "//web:node_modules/pino", + "//web:node_modules/react", + "//web:node_modules/react-dom", + "//web/platform:lib", + "//web/service/shared:lib", + ] + glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]), +) + +next.next_binary( + name = "next", +) + +js_run_devserver( + name = "dev", + args = [ + "dev", + "--webpack", + "--hostname", + "0.0.0.0", + "--port", + "0", + ], + chdir = package_name(), + data = [ + "next.config.ts", + "next-env.d.ts", + "package.json", + "tsconfig.json", + ":node_modules", + "//web/platform:lib", + "//web/service/shared:lib", + "//web/stovepipe:lib", + ] + glob([ + "src/**", + "public/**", + ]), + env = {"NEXT_TELEMETRY_DISABLED": "1"}, + grant_sandbox_write_permissions = True, + tool = ":next", +) + +nextjs_standalone_build( + name = "standalone", + srcs = ["next-env.d.ts"] + glob([ + "src/**", + ]), + args = ["--webpack"], + config = "next.config.ts", + data = [ + "package.json", + "tsconfig.json", + ":node_modules", + ":node_modules/@bufbuild/protobuf", + ":node_modules/@connectrpc/connect", + ":node_modules/@connectrpc/connect-node", + ":node_modules/@opentelemetry/api", + ":node_modules/@submitqueue/api", + ":node_modules/@submitqueue/web-service-shared", + ":node_modules/@submitqueue/web-stovepipe", + ":node_modules/next", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/server-only", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@connectrpc/connect-node", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/next", + "//web:node_modules/pino", + "//web:node_modules/react", + "//web:node_modules/react-dom", + "//web/platform:lib", + "//web/platform:lib_types", + "//web/platform:pkg", + "//web/service/shared:lib", + "//web/service/shared:lib_types", + "//web/stovepipe:lib", + "//web/stovepipe:lib_types", + ], + env = {"NEXT_TELEMETRY_DISABLED": "1"}, + next_js_binary = ":next", +) + +nextjs_standalone_server( + name = "server", + app = ":standalone", + # Next.js leaves pino unbundled, so the runtime layer must supply it. + data = [ + ":node_modules/next", + ":node_modules/pino", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/server-only", + ], + pkg = "service/stovepipe", +) + +platform( + name = "linux_amd64", + constraint_values = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], +) + +js_image_layer( + name = "server_layers", + binary = ":server", + platform = ":linux_amd64", + root = "/app", +) + +oci_image( + name = "image", + base = "@node_22_20_0_bookworm_slim_linux_amd64", + entrypoint = ["/app/web/service/stovepipe/server"], + env = { + "HOSTNAME": "0.0.0.0", + "NODE_ENV": "production", + "PORT": "3000", + }, + tars = [":server_layers"], + user = "node", + workdir = "/app/web/service/stovepipe/server.runfiles/_main", +) + +oci_load( + name = "image_load", + image = ":image", + repo_tags = ["stovepipe-web-service:latest"], +) + +exports_files(["docker-compose.yml"]) diff --git a/web/service/stovepipe/README.md b/web/service/stovepipe/README.md new file mode 100644 index 000000000..1dfc415d5 --- /dev/null +++ b/web/service/stovepipe/README.md @@ -0,0 +1,23 @@ +# Stovepipe reference host + +This Next.js host mounts the Stovepipe UI and supplies a server-side gRPC client, Basic authentication and JSON logging. The backend must support `List` and `GetRequestHistoryByID`, including the current summary in history responses. + +## Local startup + +Run `make local-stovepipe-web-start` to build and start the backend and web UI. Compose uses its private network for backend calls, waits for `/healthz`, and publishes the web UI on a free port. Open the printed URL and sign in as `test` with password `test`, or the configured `STOVEPIPE_WEB_TOKEN`. Run `make local-stovepipe-stop` to stop the stack. + +For a separate backend or development server, configure the environment below and run `./tool/bazel run //web/service/stovepipe:dev`. Next.js prints its free-port URL. Bazel stages a writable development directory; rerun after source edits, or use a Bazel watcher to synchronize changes. + +| Variable | Purpose | +| --- | --- | +| `STOVEPIPE_URL` | Backend origin; the local overlay sets `http://stovepipe-service:8080`. | +| `STOVEPIPE_ALLOW_PLAINTEXT` | Set `true` for local HTTP/2 without TLS. | +| `STOVEPIPE_WEB_TOKEN` | Required Basic authentication password; the local overlay defaults to `test`. | +| `STOVEPIPE_WEB_QUEUES` | JSON array of queue objects with unique `name` values matching the backend. The local overlay supplies the demo queues. | +| `LOG_LEVEL` | JSON logging level; defaults to `info`. | + +A queue such as `monorepo/main` appears at `/monorepo%2Fmain`; request `7` appears at `/monorepo%2Fmain/request/7`. + +## Validation and images + +Run `./tool/bazel test //web:check` for library, host, lint and package checks. Build the deployment image with `./tool/bazel build //web/service/stovepipe:image`, or load it into Docker with `./tool/bazel run //web/service/stovepipe:image_load`. diff --git a/web/service/stovepipe/docker-compose.yml b/web/service/stovepipe/docker-compose.yml new file mode 100644 index 000000000..03a1893f5 --- /dev/null +++ b/web/service/stovepipe/docker-compose.yml @@ -0,0 +1,26 @@ +# Local web overlay: the host connects to Stovepipe on Compose's private network. +services: + web-service: + image: ${STOVEPIPE_WEB_IMAGE:-stovepipe-web-service:latest} + platform: linux/amd64 + ports: + - "3000" + environment: + STOVEPIPE_URL: http://stovepipe-service:8080 + STOVEPIPE_ALLOW_PLAINTEXT: "true" + STOVEPIPE_WEB_TOKEN: ${STOVEPIPE_WEB_TOKEN:-test} + STOVEPIPE_WEB_QUEUES: ${STOVEPIPE_WEB_QUEUES:-[{"name":"monorepo/main"},{"name":"monorepo/release"},{"name":"monorepo/slow"}]} + depends_on: + stovepipe-service: + condition: service_started + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 2s + timeout: 2s + retries: 30 diff --git a/web/service/stovepipe/eslint.config.mjs b/web/service/stovepipe/eslint.config.mjs new file mode 100644 index 000000000..db21edbe5 --- /dev/null +++ b/web/service/stovepipe/eslint.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; + +export default defineConfig([ + ...nextVitals, + globalIgnores([".next/**", "node_modules/**"]), +]); diff --git a/web/service/stovepipe/next-env.d.ts b/web/service/stovepipe/next-env.d.ts new file mode 100644 index 000000000..ce4e94a6b --- /dev/null +++ b/web/service/stovepipe/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/service/stovepipe/next.config.ts b/web/service/stovepipe/next.config.ts new file mode 100644 index 000000000..e069e27f9 --- /dev/null +++ b/web/service/stovepipe/next.config.ts @@ -0,0 +1,24 @@ +import type { NextConfig } from "next"; +import path from "node:path"; + +const nextConfig: NextConfig = { + experimental: { + authInterrupts: true, + }, + // Pages already wait for their data, so resolve titles before streaming rather + // than leaving the document untitled until streamed metadata arrives. + htmlLimitedBots: /.*/, + output: "standalone", + outputFileTracingRoot: path.join(import.meta.dirname, "../.."), + outputFileTracingExcludes: { + "*": ["**/node_modules/**"], + }, + transpilePackages: ["@submitqueue/web-platform", "@submitqueue/web-service-shared", "@submitqueue/api", "@submitqueue/web-stovepipe"], + webpack(config) { + // Bazel's linked workspace packages resolve their peers from this host. + config.resolve.modules.push(path.join(import.meta.dirname, "node_modules")); + return config; + }, +}; + +export default nextConfig; diff --git a/web/service/stovepipe/package.json b/web/service/stovepipe/package.json new file mode 100644 index 000000000..8f61edd79 --- /dev/null +++ b/web/service/stovepipe/package.json @@ -0,0 +1,37 @@ +{ + "name": "@submitqueue/service-stovepipe-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "next build", + "dev": "next dev --hostname 0.0.0.0 --port 0", + "lint": "eslint .", + "start": "next start --hostname 0.0.0.0 --port 0", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bufbuild/protobuf": "2.15.0", + "@connectrpc/connect": "^2.1.1", + "@connectrpc/connect-node": "^2.1.1", + "@opentelemetry/api": "1.9.0", + "@submitqueue/api": "0.1.0", + "next": ">=16.3.0 <17", + "pino": "^10.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "server-only": "^0.0.1", + "@submitqueue/web-stovepipe": "0.0.0", + "@submitqueue/web-service-shared": "0.0.0" + }, + "devDependencies": { + "@types/node": "22.18.6", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "eslint": "^9.36.0", + "eslint-config-next": ">=16.3.0 <17", + "typescript": "5.9.3", + "vitest": "4.0.7" + } +} diff --git a/web/service/stovepipe/public/robots.txt b/web/service/stovepipe/public/robots.txt new file mode 100644 index 000000000..eb0536286 --- /dev/null +++ b/web/service/stovepipe/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/web/service/stovepipe/src/app/(protected)/[[...path]]/page.tsx b/web/service/stovepipe/src/app/(protected)/[[...path]]/page.tsx new file mode 100644 index 000000000..d8731a0fe --- /dev/null +++ b/web/service/stovepipe/src/app/(protected)/[[...path]]/page.tsx @@ -0,0 +1,3 @@ +export { StovepipePage as default } from "../../../next/page"; + +export const dynamic = "force-dynamic"; diff --git a/web/service/stovepipe/src/app/healthz/route.test.ts b/web/service/stovepipe/src/app/healthz/route.test.ts new file mode 100644 index 000000000..1312b8ccb --- /dev/null +++ b/web/service/stovepipe/src/app/healthz/route.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./route"; + +const { ping } = vi.hoisted(() => ({ ping: vi.fn() })); +vi.mock("@connectrpc/connect", () => ({ createClient: () => ({ ping }) })); + +beforeEach(() => { + vi.stubEnv("STOVEPIPE_URL", "http://localhost:8080"); + vi.stubEnv("STOVEPIPE_ALLOW_PLAINTEXT", "true"); + vi.stubEnv( + "STOVEPIPE_WEB_QUEUES", + '[{"name":"repo/main"}]', + ); + vi.stubEnv("STOVEPIPE_WEB_TOKEN", "test"); + ping.mockReset().mockResolvedValue({}); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe("readiness", () => { + it("is ready only when configuration and the backend are available", async () => { + const response = await GET(); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + ping.mockRejectedValueOnce(new Error("unavailable")); + expect((await GET()).status).toBe(503); + }); + + it.each([undefined, ""])( + "rejects missing or empty auth configuration (%s)", + async (token) => { + vi.stubEnv("STOVEPIPE_WEB_TOKEN", token); + expect((await GET()).status).toBe(503); + expect(ping).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/web/service/stovepipe/src/app/healthz/route.ts b/web/service/stovepipe/src/app/healthz/route.ts new file mode 100644 index 000000000..62751e408 --- /dev/null +++ b/web/service/stovepipe/src/app/healthz/route.ts @@ -0,0 +1,29 @@ +import { createClient } from "@connectrpc/connect"; +import { Stovepipe } from "@submitqueue/api/stovepipe"; +import { createNextHealthRoute } from "@submitqueue/web-service-shared/health"; +import { loadConfiguration } from "../../server/config"; +import { loadAuthConfiguration } from "../../server/auth"; +import { newConnectTransport } from "@submitqueue/web-service-shared/transport"; +import { logger } from "../../server/host"; + +export const dynamic = "force-dynamic"; + +export const GET = createNextHealthRoute( + { + configuration: () => { + loadAuthConfiguration(); + loadConfiguration(); + }, + stovepipe: async () => { + const client = createClient( + Stovepipe, + newConnectTransport({ + ...loadConfiguration(), + defaultTimeoutMs: 1_000, + }), + ); + await client.ping({ message: "stovepipe-web-readiness" }); + }, + }, + logger, +); diff --git a/web/service/stovepipe/src/app/layout.tsx b/web/service/stovepipe/src/app/layout.tsx new file mode 100644 index 000000000..60c6884ca --- /dev/null +++ b/web/service/stovepipe/src/app/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import "@submitqueue/web-stovepipe/styles.css"; +export const metadata = { + title: "Stovepipe", + description: "Stovepipe request status and history.", +}; +export default function Layout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/service/stovepipe/src/app/not-found.tsx b/web/service/stovepipe/src/app/not-found.tsx new file mode 100644 index 000000000..b21df8a3b --- /dev/null +++ b/web/service/stovepipe/src/app/not-found.tsx @@ -0,0 +1,8 @@ +export default function NotFound() { + return ( +
+

Page not found

+

The queue or route is not configured.

+
+ ); +} diff --git a/web/service/stovepipe/src/app/unauthorized.tsx b/web/service/stovepipe/src/app/unauthorized.tsx new file mode 100644 index 000000000..0261efa3b --- /dev/null +++ b/web/service/stovepipe/src/app/unauthorized.tsx @@ -0,0 +1,8 @@ +export default function Unauthorized() { + return ( +
+

Authentication required

+

Sign in with the configured demo credentials.

+
+ ); +} diff --git a/web/service/stovepipe/src/next/authorize.ts b/web/service/stovepipe/src/next/authorize.ts new file mode 100644 index 000000000..f5fc52891 --- /dev/null +++ b/web/service/stovepipe/src/next/authorize.ts @@ -0,0 +1,15 @@ +import "server-only"; + +import { headers } from "next/headers"; +import { unauthorized } from "next/navigation"; + +import { isAuthorized, loadAuthConfiguration } from "../server/auth"; + +export async function requireAuthorization(): Promise { + const requestHeaders = await headers(); + if ( + !isAuthorized(requestHeaders.get("authorization"), loadAuthConfiguration()) + ) { + unauthorized(); + } +} diff --git a/web/service/stovepipe/src/next/page.tsx b/web/service/stovepipe/src/next/page.tsx new file mode 100644 index 000000000..a2d29586e --- /dev/null +++ b/web/service/stovepipe/src/next/page.tsx @@ -0,0 +1,34 @@ +import { forbidden, notFound, redirect } from "next/navigation"; +import { requireAuthorization } from "./authorize"; +import { resolveHost } from "../server/host"; +import { StovepipeApp } from "@submitqueue/web-stovepipe"; +import { NextNavigationProvider } from "@submitqueue/web-service-shared/navigation"; +export async function StovepipePage({ + params, + searchParams, +}: { + params: Promise<{ path?: string[] }>; + searchParams: Promise>; +}) { + await requireAuthorization(); + const [{ path = [] }, values] = await Promise.all([params, searchParams]); + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (typeof value === "string") search.set(key, value); + else if (Array.isArray(value)) + for (const item of value) search.append(key, item); + } + // Next preserves encoded slashes in catch-all params; the module decodes each segment once. + const result = await resolveHost().handle({ + path: `/${path.join("/")}`, + search, + }); + if (result.kind === "not-found") notFound(); + if (result.kind === "forbidden") forbidden(); + if (result.kind === "redirect") redirect(result.href); + return ( + + + + ); +} diff --git a/web/service/stovepipe/src/proxy.ts b/web/service/stovepipe/src/proxy.ts new file mode 100644 index 000000000..7a8071c94 --- /dev/null +++ b/web/service/stovepipe/src/proxy.ts @@ -0,0 +1,23 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { + authenticationChallenge, + isAuthorized, + loadAuthConfiguration, +} from "./server/auth"; + +// Pages repeat the check themselves; this only challenges anonymous browsers early. +export function proxy(request: NextRequest): Response { + return isAuthorized( + request.headers.get("authorization"), + loadAuthConfiguration(), + ) + ? NextResponse.next() + : authenticationChallenge(); +} + +export const config = { + matcher: [ + "/((?!healthz$|_next/static|_next/image|favicon.ico|robots.txt).*)", + ], +}; diff --git a/web/service/stovepipe/src/server/auth.test.ts b/web/service/stovepipe/src/server/auth.test.ts new file mode 100644 index 000000000..a9fd38ec2 --- /dev/null +++ b/web/service/stovepipe/src/server/auth.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { authenticationChallenge, loadAuthConfiguration } from "./auth"; + +const token = "test"; + +describe("loadAuthConfiguration", () => { + it("loads the local demo password", () => { + expect(loadAuthConfiguration({ STOVEPIPE_WEB_TOKEN: token })).toEqual({ + token, + }); + }); + + it.each([undefined, ""])("rejects a missing or empty token", (value) => { + expect(() => loadAuthConfiguration({ STOVEPIPE_WEB_TOKEN: value })).toThrow( + Error, + ); + }); +}); + +it("uses the host-specific authentication realm", () => { + expect(authenticationChallenge().headers.get("www-authenticate")).toBe( + 'Basic realm="Stovepipe demo", charset="UTF-8"', + ); +}); diff --git a/web/service/stovepipe/src/server/auth.ts b/web/service/stovepipe/src/server/auth.ts new file mode 100644 index 000000000..873f9a25e --- /dev/null +++ b/web/service/stovepipe/src/server/auth.ts @@ -0,0 +1,37 @@ +import "server-only"; + +import { + isAuthorized as checkCredentials, + authenticationChallenge as challenge, +} from "@submitqueue/web-service-shared/auth"; + +export const BASIC_AUTH_USERNAME = "test"; +export const BASIC_AUTH_REALM = "Stovepipe demo"; + +export type AuthConfiguration = Readonly<{ + token: string; +}>; + +export function loadAuthConfiguration( + environment: Readonly> = process.env, +): AuthConfiguration { + const token = environment.STOVEPIPE_WEB_TOKEN ?? ""; + if (token.length === 0) { + throw new Error("STOVEPIPE_WEB_TOKEN must be set"); + } + return { token }; +} + +export function isAuthorized( + authorization: string | null, + configuration: AuthConfiguration, +): boolean { + return checkCredentials(authorization, { + ...configuration, + username: BASIC_AUTH_USERNAME, + }); +} + +export function authenticationChallenge(): Response { + return challenge(BASIC_AUTH_REALM); +} diff --git a/web/service/stovepipe/src/server/config.test.ts b/web/service/stovepipe/src/server/config.test.ts new file mode 100644 index 000000000..3013b1009 --- /dev/null +++ b/web/service/stovepipe/src/server/config.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { loadConfiguration } from "./config"; +const environment = { + STOVEPIPE_URL: "http://127.0.0.1:8080", + STOVEPIPE_ALLOW_PLAINTEXT: "true", +}; +describe("Stovepipe host configuration", () => { + it("preserves queue identities", () => { + const queues = [ + { name: "repo/main" }, + { name: "repo/empty" }, + ]; + expect( + loadConfiguration({ + ...environment, + STOVEPIPE_WEB_QUEUES: JSON.stringify(queues), + }).queues, + ).toEqual(queues); + }); + it("rejects missing or ambiguous queue configuration", () => { + for (const queues of [ + [], + [{}], + [{ name: "" }], + [{ name: 7 }], + [{ name: "q" }, { name: "q" }], + [null], + ]) { + expect(() => + loadConfiguration({ + ...environment, + STOVEPIPE_WEB_QUEUES: JSON.stringify(queues), + }), + ).toThrow(); + } + }); +}); diff --git a/web/service/stovepipe/src/server/config.ts b/web/service/stovepipe/src/server/config.ts new file mode 100644 index 000000000..616cc500d --- /dev/null +++ b/web/service/stovepipe/src/server/config.ts @@ -0,0 +1,32 @@ +import "server-only"; +import { parseServiceBaseUrl } from "@submitqueue/web-service-shared/transport"; +import type { Queue } from "@submitqueue/web-stovepipe/server"; +export function loadConfiguration( + environment: Readonly> = process.env, +) { + const baseUrl = parseServiceBaseUrl( + environment.STOVEPIPE_URL ?? "", + environment.STOVEPIPE_ALLOW_PLAINTEXT === "true", + ); + const value: unknown = JSON.parse(environment.STOVEPIPE_WEB_QUEUES ?? "[]"); + if (!Array.isArray(value) || value.length === 0) + throw new Error("STOVEPIPE_WEB_QUEUES must contain at least one queue"); + const queues: Queue[] = []; + for (const queue of value) { + if ( + typeof queue !== "object" || + queue === null || + typeof queue.name !== "string" || + !queue.name + ) + throw new Error("Each queue needs a name"); + if (queues.some((item) => item.name === queue.name)) + throw new Error("Queue names must be unique"); + queues.push({ name: queue.name }); + } + return { + baseUrl, + allowPlaintext: environment.STOVEPIPE_ALLOW_PLAINTEXT === "true", + queues, + }; +} diff --git a/web/service/stovepipe/src/server/host.ts b/web/service/stovepipe/src/server/host.ts new file mode 100644 index 000000000..8ec309cd8 --- /dev/null +++ b/web/service/stovepipe/src/server/host.ts @@ -0,0 +1,28 @@ +import "server-only"; +import { createClient } from "@connectrpc/connect"; +import { Stovepipe } from "@submitqueue/api/stovepipe"; +import { createStovepipeWeb } from "@submitqueue/web-stovepipe/server"; +import { pino } from "pino"; +import { loadConfiguration } from "./config"; +import { newConnectTransport } from "@submitqueue/web-service-shared/transport"; +export const logger = pino({ + name: "stovepipe-web", + level: process.env.LOG_LEVEL ?? "info", +}); + +let host: ReturnType | undefined; +export function resolveHost() { + if (!host) { + const configuration = loadConfiguration(); + const service = createClient( + Stovepipe, + newConnectTransport({ ...configuration, defaultTimeoutMs: 5_000 }), + ); + host = createStovepipeWeb({ + service, + queues: configuration.queues, + logger, + }); + } + return host; +} diff --git a/web/service/stovepipe/src/test/server-only.ts b/web/service/stovepipe/src/test/server-only.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/web/service/stovepipe/src/test/server-only.ts @@ -0,0 +1 @@ +export {}; diff --git a/web/service/stovepipe/tsconfig.json b/web/service/stovepipe/tsconfig.json new file mode 100644 index 000000000..2523b07a6 --- /dev/null +++ b/web/service/stovepipe/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "allowJs": false, + "esModuleInterop": true, + "incremental": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "plugins": [ + { + "name": "next" + } + ], + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/web/service/stovepipe/vitest.config.ts b/web/service/stovepipe/vitest.config.ts new file mode 100644 index 000000000..55fe409d1 --- /dev/null +++ b/web/service/stovepipe/vitest.config.ts @@ -0,0 +1,18 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "server-only": path.resolve(import.meta.dirname, "src/test/server-only.ts"), + }, + }, + test: { + environment: "node", + server: { + deps: { + inline: ["@submitqueue/web-service-shared", "@submitqueue/web-stovepipe"], + }, + }, + }, +}); diff --git a/web/stovepipe/BUILD.bazel b/web/stovepipe/BUILD.bazel new file mode 100644 index 000000000..779c46086 --- /dev/null +++ b/web/stovepipe/BUILD.bazel @@ -0,0 +1,34 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin") +load("@npm//web/stovepipe:vitest/package_json.bzl", vitest = "bin") +load("//web/tool:library.bzl", "web_library") + +package(default_visibility = ["//web:__subpackages__"]) + +npm_link_all_packages(name = "node_modules") + +web_library( + assets = [ + "view/styles.css", + "README.md", + ], + eslint = eslint, + package = "@submitqueue/web-stovepipe", + test_deps = [ + ":node_modules/@testing-library/jest-dom", + ":node_modules/@testing-library/react", + ":node_modules/@types/react-dom", + ":node_modules/jsdom", + ":node_modules/react-dom", + ":node_modules/vitest", + ], + vitest = vitest, + deps = [ + ":node_modules/@opentelemetry/api", + ":node_modules/@submitqueue/web-platform", + ":node_modules/@types/node", + ":node_modules/@types/react", + ":node_modules/pino", + ":node_modules/react", + ], +) diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md new file mode 100644 index 000000000..e17926426 --- /dev/null +++ b/web/stovepipe/README.md @@ -0,0 +1,15 @@ +# Stovepipe web UI + +A framework-neutral React UI with a queue request list and a request page containing the current lifecycle summary and retained history. The host supplies queue configuration, a structural `StovepipeService` client, authentication, transport, logging and OpenTelemetry providers. + +## Integration + +Use the server entry's `createStovepipeWeb` to load a page and render its model with `StovepipeApp`. Mount beneath `WebNavigationProvider` when the host supplies client navigation and refresh. Without it, links and refresh use ordinary document navigation. Import `@submitqueue/web-stovepipe/styles.css`, which includes the common native styles from `@submitqueue/web-platform`. + +## Routes and data + +Request pages use `//request/` and load both the current summary and events with `GetRequestHistoryByID`. The summary is displayed above history and is not reconstructed from retained events. Queue pagination uses the backend's opaque continuations, and the latest URL carries no time window. + +## Refresh and presentation + +Refresh is manual by default, with optional ten-second polling. Failed refreshes retain successful results on the same route; changing routes clears them. Timestamps use UTC. Lifecycle labels and outcome reasons come from the backend and are displayed verbatim. diff --git a/web/stovepipe/controller/mapping.ts b/web/stovepipe/controller/mapping.ts new file mode 100644 index 000000000..c5b996388 --- /dev/null +++ b/web/stovepipe/controller/mapping.ts @@ -0,0 +1,19 @@ +import type { RequestSummaryModel } from "../entity/index.js"; +import type { RequestSummary } from "../extension/service/index.js"; +import type { StovepipePaths } from "../core/paths.js"; + +export function mapRequestSummary( + request: RequestSummary, + paths: StovepipePaths, +): RequestSummaryModel { + return { + id: request.requestId, + href: paths.request(request.queue, request.requestId), + changeUri: request.changeUri, + baseUri: request.baseUri, + state: request.requestState, + updatedAtMs: request.stateUpdatedAtMs.toString(), + acceptedAtMs: request.acceptedAtMs.toString(), + outcomeReason: request.outcomeReason, + }; +} diff --git a/web/stovepipe/controller/page.ts b/web/stovepipe/controller/page.ts new file mode 100644 index 000000000..a0688e0dd --- /dev/null +++ b/web/stovepipe/controller/page.ts @@ -0,0 +1,18 @@ +import type { Logger } from "pino"; +import type { ReadResult } from "../entity/index.js"; + +export async function readSection( + operation: () => Promise, + logger: Logger, + section: string, +): Promise> { + try { + return { ok: true, data: await operation() }; + } catch (err) { + logger.warn({ err, section }, "Stovepipe page section failed"); + return { + ok: false, + error: `Unable to load ${section}. Try refreshing. If the problem continues, check that the request exists and that the service is available.`, + }; + } +} diff --git a/web/stovepipe/controller/queue.ts b/web/stovepipe/controller/queue.ts new file mode 100644 index 000000000..ff08e5283 --- /dev/null +++ b/web/stovepipe/controller/queue.ts @@ -0,0 +1,24 @@ +import type { StovepipeService } from "../extension/service/index.js"; +import type { StovepipePaths } from "../core/paths.js"; +import { mapRequestSummary } from "./mapping.js"; + +export async function loadQueueRequests( + service: StovepipeService, + paths: StovepipePaths, + queue: string, + pageToken: string, + signal?: AbortSignal, +) { + const result = await service.list( + { queue, pageSize: 50, pageToken }, + signal ? { signal } : {}, + ); + return { + requests: result.requests.map((request) => + mapRequestSummary(request, paths), + ), + nextHref: result.nextPageToken + ? paths.queue(queue, result.nextPageToken) + : null, + }; +} diff --git a/web/stovepipe/controller/request.ts b/web/stovepipe/controller/request.ts new file mode 100644 index 000000000..dede07cdf --- /dev/null +++ b/web/stovepipe/controller/request.ts @@ -0,0 +1,30 @@ +import type { RequestHistoryModel } from "../entity/index.js"; +import type { StovepipeService } from "../extension/service/index.js"; +import type { StovepipePaths } from "../core/paths.js"; +import { mapRequestSummary } from "./mapping.js"; + +export async function loadRequestHistory( + service: StovepipeService, + paths: StovepipePaths, + queue: string, + requestId: string, + signal?: AbortSignal, +): Promise { + const result = await service.getRequestHistoryByID( + { queue, requestId }, + signal ? { signal } : {}, + ); + const summary = result.requestSummary; + if (!summary || summary.requestId !== requestId || summary.queue !== queue) + throw new Error("History response does not identify the selected request"); + return { + request: mapRequestSummary(summary, paths), + events: result.events.map((event) => ({ + id: event.eventId, + timestampMs: event.timestampMs.toString(), + kind: event.occurrence.case ?? "unknown", + label: event.occurrence.value ?? "Unknown event", + outcomeReason: event.outcomeReason, + })), + }; +} diff --git a/web/stovepipe/core/paths.ts b/web/stovepipe/core/paths.ts new file mode 100644 index 000000000..4f9371508 --- /dev/null +++ b/web/stovepipe/core/paths.ts @@ -0,0 +1,15 @@ +import { encodePathSegment } from "@submitqueue/web-platform/path-segment"; + +export class StovepipePaths { + readonly basePath: string; + constructor(basePath = "") { + this.basePath = basePath.replace(/\/$/u, ""); + } + queue(queue: string, page = ""): string { + const path = `${this.basePath}/${encodePathSegment(queue)}`; + return page ? `${path}?${new URLSearchParams({ page })}` : path; + } + request(queue: string, requestId: string): string { + return `${this.queue(queue)}/request/${encodePathSegment(requestId)}`; + } +} diff --git a/web/stovepipe/core/route.ts b/web/stovepipe/core/route.ts new file mode 100644 index 000000000..016f80382 --- /dev/null +++ b/web/stovepipe/core/route.ts @@ -0,0 +1,32 @@ +import { decodePathSegment } from "@submitqueue/web-platform/path-segment"; +import type { Queue } from "../entity/index.js"; +import type { StovepipePaths } from "./paths.js"; + +export function parseStovepipeRoute( + path: string, + paths: StovepipePaths, + queues: readonly Queue[], +) { + if (!path.startsWith(`${paths.basePath}/`)) return null; + let segments: string[]; + try { + segments = path + .slice(paths.basePath.length + 1) + .replace(/\/$/u, "") + .split("/") + .map((segment) => decodePathSegment(decodeURIComponent(segment))); + } catch { + return null; + } + const queue = queues.find((queue) => queue.name === segments[0]); + if (!queue) return null; + if (segments.length === 1) + return { queue, requestId: "" }; + if ( + segments.length !== 3 || + segments[1] !== "request" || + !segments[2] + ) + return null; + return { queue, requestId: segments[2] }; +} diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts new file mode 100644 index 000000000..5e6a2a951 --- /dev/null +++ b/web/stovepipe/entity/index.ts @@ -0,0 +1,74 @@ +export interface Queue { + /** Queue name, preserved verbatim in the UI. */ + name: string; +} +export type ReadResult = + | { ok: true; data: T } + | { ok: false; error: string }; +export interface RequestSummaryModel { + /** Request identifier within its queue. */ + id: string; + /** In-app URL of this request. */ + href: string; + /** Exact URI of the ingested commit. */ + changeUri: string; + /** Baseline URI, empty when none is selected. */ + baseUri: string; + /** Backend lifecycle label, including future values. */ + state: string; + /** Unix milliseconds as a decimal string, preserving wire int64 precision. */ + updatedAtMs: string; + /** Original acceptance time in Unix milliseconds; zero means unknown. */ + acceptedAtMs: string; + /** Stable backend outcome reason, empty when absent. */ + outcomeReason: string; +} +export interface HistoryEventModel { + /** Stable event identifier within the request. */ + id: string; + /** Occurrence time in Unix milliseconds. */ + timestampMs: string; + /** Occurrence type, independent of the lifecycle label. */ + kind: "requestState" | "event" | "unknown"; + /** Backend state or event label, shown verbatim. */ + label: string; + /** Backend reason associated with this occurrence. */ + outcomeReason: string; +} +export interface RequestHistoryModel { + /** Current materialized lifecycle summary returned with the history. */ + request: RequestSummaryModel; + /** Retained events ordered by occurrence time and stable event identity. */ + events: HistoryEventModel[]; +} +interface PageBase { + /** Route and cursor identity; independent of refresh time. */ + key: string; + /** Selected queue name. */ + queue: string; + /** Configured queue names and their list URLs. */ + queues: { name: string; href: string }[]; + /** Selected queue's latest request list. */ + queueHref: string; + /** This view without pagination. */ + latestHref: string; + /** Whether this view has a pagination continuation. */ + showLatestLink: boolean; + /** Page load time in Unix milliseconds. */ + loadedAtMs: number; +} +export type StovepipePageModel = PageBase & + ( + | { + kind: "queue"; + requests: ReadResult<{ + requests: RequestSummaryModel[]; + nextHref: string | null; + }>; + } + | { + kind: "request"; + requestId: string; + history: ReadResult; + } + ); diff --git a/web/stovepipe/eslint.config.mjs b/web/stovepipe/eslint.config.mjs new file mode 100644 index 000000000..6ca27cb54 --- /dev/null +++ b/web/stovepipe/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "../tool/eslint.config.mjs"; + +export default [...config, { rules: { "max-statements-per-line": ["error", { max: 1 }] } }]; diff --git a/web/stovepipe/extension/service/index.ts b/web/stovepipe/extension/service/index.ts new file mode 100644 index 000000000..e8f5af902 --- /dev/null +++ b/web/stovepipe/extension/service/index.ts @@ -0,0 +1,34 @@ +export interface RequestSummary { + requestId: string; + queue: string; + changeUri: string; + baseUri: string; + requestState: string; + stateUpdatedAtMs: bigint; + acceptedAtMs: bigint; + outcomeReason: string; +} + +export interface HistoryEvent { + eventId: string; + timestampMs: bigint; + occurrence: + | { case: "requestState" | "event"; value: string } + | { case: undefined; value?: undefined }; + outcomeReason: string; +} + +/** Hosts supply a transport client; the library has no generated-binding dependency. */ +export interface StovepipeService { + list( + request: { queue: string; pageSize: number; pageToken: string }, + options?: { signal?: AbortSignal }, + ): Promise<{ requests: RequestSummary[]; nextPageToken: string }>; + getRequestHistoryByID( + request: { queue: string; requestId: string }, + options?: { signal?: AbortSignal }, + ): Promise<{ + requestSummary?: RequestSummary | undefined; + events: HistoryEvent[]; + }>; +} diff --git a/web/stovepipe/index.ts b/web/stovepipe/index.ts new file mode 100644 index 000000000..80ad8685c --- /dev/null +++ b/web/stovepipe/index.ts @@ -0,0 +1,8 @@ +"use client"; +export { StovepipeApp } from "./view/app.js"; +export type { StovepipePageModel } from "./entity/index.js"; +export { + WebNavigationProvider, + type WebLinkProps, + type WebNavigation, +} from "@submitqueue/web-platform"; diff --git a/web/stovepipe/module.test.ts b/web/stovepipe/module.test.ts new file mode 100644 index 000000000..23f63db10 --- /dev/null +++ b/web/stovepipe/module.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vitest"; +import { createStovepipeWeb } from "./module.js"; +import { StovepipePaths } from "./core/paths.js"; +import type { + StovepipeService, + RequestSummary, +} from "./extension/service/index.js"; + +const summary: RequestSummary = { + requestId: "9007199254740993", + queue: "repo/main", + changeUri: "git://repo/main/sha?x=1&file=src%2Fmain#fragment", + baseUri: "git://repo/main/base", + requestState: "future_state", + stateUpdatedAtMs: 9007199254740993n, + acceptedAtMs: 100n, + outcomeReason: "future_reason", +}; +function createPageTestContext() { + const service: StovepipeService = { + list: vi + .fn() + .mockResolvedValue({ requests: [], nextPageToken: "older+/page" }), + getRequestHistoryByID: vi.fn().mockResolvedValue({ + requestSummary: summary, + events: [ + { + eventId: "e1", + timestampMs: 100n, + occurrence: { case: "event", value: "future_event" }, + outcomeReason: "event_reason", + }, + ], + }), + }; + const paths = new StovepipePaths("/stovepipe"); + const app = createStovepipeWeb({ + service, + paths, + queues: [{ name: "repo/main" }], + }); + return { service, paths, app }; +} +describe("Stovepipe page loading", () => { + it("uses backend queue cursors and links each request by its ID", async () => { + const { service, app, paths } = createPageTestContext(); + vi.mocked(service.list).mockResolvedValue({ + requests: [summary, { ...summary, requestId: "7" }], + nextPageToken: "older+/page", + }); + const result = await app.handle({ + path: paths.queue("repo/main"), + search: new URLSearchParams({ page: "opaque+/cursor" }), + }); + expect(service.list).toHaveBeenCalledWith( + { queue: "repo/main", pageSize: 50, pageToken: "opaque+/cursor" }, + {}, + ); + if ( + result.kind !== "render" || + result.model.kind !== "queue" || + !result.model.requests.ok + ) + throw new Error("No queue page"); + expect(result.model.requests.data.nextHref).toBe( + paths.queue("repo/main", "older+/page"), + ); + expect( + result.model.requests.data.requests.map((request) => request.href), + ).toEqual([ + paths.request("repo/main", summary.requestId), + paths.request("repo/main", "7"), + ]); + expect(service.getRequestHistoryByID).not.toHaveBeenCalled(); + }); + it("loads the current summary and events by ID without reconstructing state from history", async () => { + const { app, service, paths } = createPageTestContext(); + const signal = new AbortController().signal; + const result = await app.handle({ + path: paths.request("repo/main", summary.requestId), + search: new URLSearchParams(), + signal, + }); + expect(service.getRequestHistoryByID).toHaveBeenCalledWith( + { queue: "repo/main", requestId: summary.requestId }, + { signal }, + ); + expect(result).toMatchObject({ + kind: "render", + model: { + kind: "request", + requestId: summary.requestId, + history: { + ok: true, + data: { + request: { + id: summary.requestId, + changeUri: summary.changeUri, + baseUri: summary.baseUri, + state: "future_state", + updatedAtMs: "9007199254740993", + acceptedAtMs: "100", + outcomeReason: "future_reason", + }, + events: [{ label: "future_event", outcomeReason: "event_reason" }], + }, + }, + }, + }); + expect(service.list).not.toHaveBeenCalled(); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + it("retains the current summary when there are no retained events", async () => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.getRequestHistoryByID).mockResolvedValue({ + requestSummary: summary, + events: [], + }); + const result = await app.handle({ + path: paths.request("repo/main", summary.requestId), + search: new URLSearchParams(), + }); + expect(result).toMatchObject({ + kind: "render", + model: { + history: { + ok: true, + data: { request: { state: "future_state" }, events: [] }, + }, + }, + }); + }); + it.each([ + { name: "missing summary", requestSummary: undefined }, + { name: "another request", requestSummary: { ...summary, requestId: "7" } }, + { name: "another queue", requestSummary: { ...summary, queue: "other" } }, + ])("rejects history with $name", async ({ requestSummary }) => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.getRequestHistoryByID).mockResolvedValue({ + requestSummary, + events: [], + }); + const result = await app.handle({ + path: paths.request("repo/main", summary.requestId), + search: new URLSearchParams(), + }); + expect(result).toMatchObject({ + kind: "render", + model: { history: { ok: false } }, + }); + }); + it("renders a section error if history is unavailable", async () => { + const { app, service, paths } = createPageTestContext(); + vi.mocked(service.getRequestHistoryByID).mockRejectedValue( + new Error("unavailable"), + ); + const result = await app.handle({ + path: paths.request("repo/main", summary.requestId), + search: new URLSearchParams(), + }); + expect(result).toMatchObject({ + kind: "render", + model: { requestId: summary.requestId, history: { ok: false } }, + }); + }); + it("blocks unauthorized loads before calling the service", async () => { + const { service, paths } = createPageTestContext(); + const app = createStovepipeWeb({ + service, + paths, + queues: [{ name: "repo/main" }], + authorize: () => false, + }); + const result = await app.handle({ + path: paths.request("repo/main", summary.requestId), + search: new URLSearchParams(), + }); + expect(result).toEqual({ kind: "forbidden" }); + expect(service.getRequestHistoryByID).not.toHaveBeenCalled(); + }); + it.each([ + "/other", + "/stovepipe/%", + "/stovepipe/unknown", + "/stovepipe/repo%2Fmain/change/uri", + "/stovepipe/repo%2Fmain/change/uri/projects.json", + "/stovepipe/repo%2Fmain/request/", + "/stovepipe/repo%2Fmain/request/7/extra", + ])("rejects invalid or removed route %s", async (path) => { + const { app, service } = createPageTestContext(); + expect( + await app.handle({ path, search: new URLSearchParams() }), + ).toEqual({ kind: "not-found" }); + expect(service.list).not.toHaveBeenCalled(); + expect(service.getRequestHistoryByID).not.toHaveBeenCalled(); + }); +}); diff --git a/web/stovepipe/module.ts b/web/stovepipe/module.ts new file mode 100644 index 000000000..bf1e4e740 --- /dev/null +++ b/web/stovepipe/module.ts @@ -0,0 +1,134 @@ +import { metrics, trace, type Meter, type Tracer } from "@opentelemetry/api"; +import { pino, type Logger } from "pino"; +import { parseStovepipeRoute } from "./core/route.js"; +import type { StovepipeService } from "./extension/service/index.js"; +import type { Queue, StovepipePageModel } from "./entity/index.js"; +import { StovepipePaths } from "./core/paths.js"; +import { readSection } from "./controller/page.js"; +import { loadQueueRequests } from "./controller/queue.js"; +import { loadRequestHistory } from "./controller/request.js"; + +export interface StovepipeWebRequest { + /** Percent-encoded URL pathname, including the mount path. */ + path: string; + search: URLSearchParams; + principal?: unknown; + signal?: AbortSignal; +} +export type StovepipeWebResult = + | { kind: "render"; model: StovepipePageModel } + | { kind: "not-found" } + | { kind: "forbidden" } + | { kind: "redirect"; href: string }; +export interface StovepipeWebOptions { + service: + | StovepipeService + | ((request: StovepipeWebRequest) => StovepipeService); + queues: readonly Queue[]; + paths?: StovepipePaths; + authorize?(principal: unknown, queue: string): boolean | Promise; + logger?: Logger; + meter?: Meter; + tracer?: Tracer; +} +export function createStovepipeWeb(options: StovepipeWebOptions) { + const paths = options.paths ?? new StovepipePaths(); + const logger = options.logger ?? pino({ enabled: false }); + const meter = options.meter ?? metrics.getMeter("stovepipe-web"); + const tracer = options.tracer ?? trace.getTracer("stovepipe-web"); + const loads = meter.createCounter("stovepipe.web.page_loads"); + const latency = meter.createHistogram("stovepipe.web.page_load_duration", { + unit: "ms", + }); + return { + async handle(request: StovepipeWebRequest): Promise { + const started = Date.now(); + return tracer.startActiveSpan( + "stovepipe.web.page_load", + async (span): Promise => { + try { + if (request.path === (paths.basePath || "/")) { + const first = options.queues[0]; + return first + ? { kind: "redirect", href: paths.queue(first.name) } + : { kind: "not-found" }; + } + const route = parseStovepipeRoute( + request.path, + paths, + options.queues, + ); + if (!route) return { kind: "not-found" }; + const { queue, requestId } = route; + if ( + options.authorize && + !(await options.authorize(request.principal, queue.name)) + ) + return { kind: "forbidden" }; + const service = + typeof options.service === "function" + ? options.service(request) + : options.service; + const common = { + key: `${request.path}?${request.search}`, + queue: queue.name, + queues: options.queues.map((queue) => ({ + name: queue.name, + href: paths.queue(queue.name), + })), + queueHref: paths.queue(queue.name), + latestHref: paths.queue(queue.name), + showLatestLink: Boolean( + !requestId && request.search.get("page"), + ), + loadedAtMs: Date.now(), + }; + if (!requestId) { + const requests = await readSection( + () => + loadQueueRequests( + service, + paths, + queue.name, + request.search.get("page") ?? "", + request.signal, + ), + logger, + "queue requests", + ); + return { + kind: "render", + model: { ...common, kind: "queue", requests }, + }; + } + const history = await readSection( + () => + loadRequestHistory( + service, + paths, + queue.name, + requestId, + request.signal, + ), + logger, + "request history", + ); + return { + kind: "render", + model: { + ...common, + kind: "request", + requestId, + history, + }, + }; + } finally { + loads.add(1); + latency.record(Date.now() - started); + span.end(); + } + }, + ); + }, + }; +} diff --git a/web/stovepipe/package.json b/web/stovepipe/package.json new file mode 100644 index 000000000..4e1b2e8ab --- /dev/null +++ b/web/stovepipe/package.json @@ -0,0 +1,52 @@ +{ + "name": "@submitqueue/web-stovepipe", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": [ + "./view/styles.css" + ], + "files": [ + "dist", + "view/styles.css" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./styles.css": "./view/styles.css" + }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json", + "lint": "eslint .", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9 <2", + "pino": ">=10 <11", + "react": ">=18 <20" + }, + "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/node": "22.18.6", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "jsdom": "^26.1.0", + "pino": "^10.4.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "typescript": "^5.8.3", + "vitest": "4.0.7" + }, + "dependencies": { + "@submitqueue/web-platform": "0.0.0" + } +} diff --git a/web/stovepipe/server.ts b/web/stovepipe/server.ts new file mode 100644 index 000000000..1ff624711 --- /dev/null +++ b/web/stovepipe/server.ts @@ -0,0 +1,9 @@ +export { + createStovepipeWeb, + type StovepipeWebOptions, + type StovepipeWebRequest, + type StovepipeWebResult, +} from "./module.js"; +export { StovepipePaths } from "./core/paths.js"; +export type { StovepipeService } from "./extension/service/index.js"; +export type { Queue } from "./entity/index.js"; diff --git a/web/stovepipe/test-setup.ts b/web/stovepipe/test-setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/web/stovepipe/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/web/stovepipe/tsconfig.build.json b/web/stovepipe/tsconfig.build.json new file mode 100644 index 000000000..0667c1a1c --- /dev/null +++ b/web/stovepipe/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.test.tsx", "test-setup.ts", "vitest.config.ts"] +} diff --git a/web/stovepipe/tsconfig.json b/web/stovepipe/tsconfig.json new file mode 100644 index 000000000..b5c8499cb --- /dev/null +++ b/web/stovepipe/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/web/stovepipe/view/app.test.tsx b/web/stovepipe/view/app.test.tsx new file mode 100644 index 000000000..76e001957 --- /dev/null +++ b/web/stovepipe/view/app.test.tsx @@ -0,0 +1,103 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { StovepipeApp } from "./app.js"; +import type { StovepipePageModel } from "../entity/index.js"; +afterEach(cleanup); +const model: StovepipePageModel = { + kind: "request", + key: "/repo%2Fmain/request/7?", + queue: "repo/main", + requestId: "7", + queues: [{ name: "repo/main", href: "/repo%2Fmain" }], + queueHref: "/repo%2Fmain", + latestHref: "/repo%2Fmain", + showLatestLink: false, + loadedAtMs: 1000, + history: { + ok: true, + data: { + request: { + id: "7", + href: "/repo%2Fmain/request/7", + changeUri: "git://repo/sha", + baseUri: "git://repo/base", + state: "completed", + updatedAtMs: "1000", + acceptedAtMs: "100", + outcomeReason: "all_targets_passed", + }, + events: [ + { + id: "e1", + timestampMs: "100", + kind: "requestState", + label: "accepted", + outcomeReason: "", + }, + ], + }, + }, +}; +it("displays the backend summary above the retained history", () => { + render(); + const status = screen.getByRole("region", { name: "Request status" }); + expect(within(status).getByText("completed")).toBeVisible(); + expect(within(status).getByText("git://repo/sha")).toBeVisible(); + expect(within(status).getByText("git://repo/base")).toBeVisible(); + expect(within(status).getByText("all_targets_passed")).toBeVisible(); + expect(within(status).getByText("Accepted")).toBeVisible(); + expect(within(status).getByText("Updated")).toBeVisible(); + const history = screen.getByRole("region", { name: "History" }); + expect(within(history).getByText("accepted")).toBeVisible(); + expect( + screen.getAllByRole("heading", { level: 2 }).map((heading) => heading.textContent), + ).toEqual(["Request status", "History"]); +}); +it("links queue entries by request ID even when the change URI is missing", () => { + if (!model.history.ok) throw new Error("No summary"); + render( + , + ); + expect(screen.getByRole("link", { name: "7" })).toHaveAttribute( + "href", + "/repo%2Fmain/request/7", + ); + expect(screen.getByText("Unknown URI")).toBeVisible(); +}); +it("retains the summary and history after a failed refresh", () => { + const { rerender } = render(); + rerender( + , + ); + expect(screen.getByText("git://repo/sha")).toBeVisible(); + expect(screen.getByText("all_targets_passed")).toBeVisible(); + expect(screen.getByText("accepted")).toBeVisible(); + expect(screen.getByText("Showing the last successful result.")).toBeVisible(); +}); +it("discards previous results when the request changes", () => { + const { rerender } = render(); + rerender( + , + ); + expect(screen.getByRole("heading", { name: "Request 8" })).toBeVisible(); + expect(screen.queryByText("git://repo/sha")).toBeNull(); + expect(screen.queryByText("all_targets_passed")).toBeNull(); +}); diff --git a/web/stovepipe/view/app.tsx b/web/stovepipe/view/app.tsx new file mode 100644 index 000000000..196e61628 --- /dev/null +++ b/web/stovepipe/view/app.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Timestamp } from "@submitqueue/web-platform"; +import { RefreshControls } from "./refresh.js"; +import type { StovepipePageModel } from "../entity/index.js"; +import { Link, Section } from "./components.js"; +import { RequestList, RequestDetails, History } from "./pages.js"; + +function Page({ model }: { model: StovepipePageModel }) { + return ( +
+
+ + SPStovepipe + +
+
+ +
+
+ {model.kind === "request" && ( +

+ ← Queue requests +

+ )} +

+ {model.kind === "queue" + ? model.queue + : `Request ${model.requestId}`} +

+

+ {model.kind === "queue" + ? "Requests ordered by acceptance time, newest first." + : model.queue} +

+
+ +
+ {model.kind === "queue" && model.showLatestLink && ( +

+ Latest page +

+ )} + {model.kind === "queue" ? ( +
+ {(data) => } +
+ ) : ( +
+ {({ request, events }) => ( +
+
+

Request status

+ +
+
+

History

+ +
+
+ )} +
+ )} +
+
+ Loaded +
+
+ ); +} +export function StovepipeApp({ model }: { model: StovepipePageModel }) { + return ; +} diff --git a/web/stovepipe/view/components.tsx b/web/stovepipe/view/components.tsx new file mode 100644 index 000000000..723dda379 --- /dev/null +++ b/web/stovepipe/view/components.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { useWebNavigation, type WebLinkProps } from "@submitqueue/web-platform"; +import type { ReadResult } from "../entity/index.js"; + +export function Link(props: WebLinkProps) { + const { Link: HostLink } = useWebNavigation(); + return HostLink ? : ; +} +export function Table({ + label, + headers, + children, +}: { + label: string; + headers: string[]; + children: ReactNode; +}) { + return ( +
+ + + + {headers.map((header) => ( + + ))} + + + {children} +
{header}
+
+ ); +} +export function Section({ + result, + children, +}: { + result: ReadResult; + children(data: T): ReactNode; +}) { + const [previous, setPrevious] = useState( + result.ok ? result.data : null, + ); + if (result.ok && result.data !== previous) setPrevious(result.data); + const data = result.ok ? result.data : previous; + return ( + <> + {!result.ok && ( +
+ {result.error} + {previous !== null &&

Showing the last successful result.

} +
+ )} + {data !== null ? children(data) : null} + + ); +} diff --git a/web/stovepipe/view/pages.tsx b/web/stovepipe/view/pages.tsx new file mode 100644 index 000000000..9d3012dc3 --- /dev/null +++ b/web/stovepipe/view/pages.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { Timestamp } from "@submitqueue/web-platform"; +import type { + RequestSummaryModel, + HistoryEventModel, +} from "../entity/index.js"; +import { Link, Table } from "./components.js"; + +function RequestTimestamp({ value }: { value: string }) { + const milliseconds = Number(value); + if (milliseconds <= 0 || Number.isNaN(new Date(milliseconds).getTime())) + return Unknown; + return ; +} + +export function RequestList({ + requests, + nextHref, +}: { + requests: RequestSummaryModel[]; + nextHref: string | null; +}) { + if (!requests.length) + return

No retained requests on this queue.

; + return ( + <> + + {requests.map((request) => ( + + + + + + + + + ))} +
+ {request.id} + + {request.changeUri || "Unknown URI"} + + + {request.state || "Unknown"} + + + + + + {request.outcomeReason || "—"}
+ {nextHref && ( +

+ Older requests → +

+ )} + + ); +} +export function RequestDetails({ request }: { request: RequestSummaryModel }) { + return ( + <> +
+
+
State
+
+ + {request.state || "Unknown"} + +
+
+
+
Change URI
+
+ {request.changeUri || "Unknown URI"} +
+
+
+
Base URI
+
+ {request.baseUri || "No baseline selected"} +
+
+
+
Accepted
+
+ +
+
+
+
Updated
+
+ +
+
+
+
Outcome
+
{request.outcomeReason || "—"}
+
+
+ + ); +} +export function History({ events }: { events: HistoryEventModel[] }) { + return events.length ? ( + + {events.map((event) => ( + + + + + + + ))} +
+ + + {event.kind === "requestState" + ? "State" + : event.kind === "event" + ? "Event" + : "Unknown"} + {event.label}{event.outcomeReason || "—"}
+ ) : ( +

No retained history yet.

+ ); +} diff --git a/web/stovepipe/view/refresh.test.tsx b/web/stovepipe/view/refresh.test.tsx new file mode 100644 index 000000000..cc13f75ba --- /dev/null +++ b/web/stovepipe/view/refresh.test.tsx @@ -0,0 +1,83 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { WebNavigationProvider } from "@submitqueue/web-platform"; +import { RefreshControls } from "./refresh.js"; + +beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, "onLine", { + configurable: true, + value: true, + }); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +it("refreshes manually until polling is enabled, then stops when disabled", async () => { + const refresh = vi.fn(async () => undefined); + render( + + + , + ); + await act(async () => vi.advanceTimersByTime(30_000)); + expect(refresh).not.toHaveBeenCalled(); + await act(async () => + fireEvent.click(screen.getByRole("button", { name: "Refresh" })), + ); + expect(refresh).toHaveBeenCalledWith(null); + + fireEvent.click(screen.getByRole("checkbox")); + await act(async () => vi.advanceTimersByTime(9_999)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByRole("checkbox")); + await act(async () => vi.advanceTimersByTime(30_000)); + expect(refresh).toHaveBeenCalledTimes(2); +}); + +it("reports a failed refresh and recovers on the next ten-second poll", async () => { + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("unavailable")) + .mockResolvedValue(undefined); + render( + + + , + ); + fireEvent.click(screen.getByRole("checkbox")); + await act(async () => vi.advanceTimersByTime(10_000)); + expect(screen.getByRole("alert")).toHaveTextContent("Refresh failed"); + expect(screen.getByRole("button", { name: "Refresh" })).toBeEnabled(); + await act(async () => vi.advanceTimersByTime(10_000)); + expect(refresh).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("removes scheduled polls when the page unmounts", async () => { + const refresh = vi.fn(async () => undefined); + const { unmount } = render( + + + , + ); + fireEvent.click(screen.getByRole("checkbox")); + unmount(); + await act(async () => vi.advanceTimersByTime(30_000)); + expect(refresh).not.toHaveBeenCalled(); +}); diff --git a/web/stovepipe/view/refresh.tsx b/web/stovepipe/view/refresh.tsx new file mode 100644 index 000000000..ca4a3d78d --- /dev/null +++ b/web/stovepipe/view/refresh.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { AutoRefresh, useWebNavigation } from "@submitqueue/web-platform"; + +export function RefreshControls() { + const navigation = useWebNavigation(); + const [automatic, setAutomatic] = useState(false); + const [error, setError] = useState(""); + const refresh = useCallback(async () => { + setError(""); + if (!navigation.refresh) { + window.location.reload(); + return; + } + try { + await navigation.refresh(null); + } catch (cause) { + setError("Refresh failed. Showing the last successful result."); + throw cause; + } + }, [navigation]); + return ( +
+ + {navigation.refresh && ( + + )} + {error &&

{error}

} +
+ ); +} diff --git a/web/stovepipe/view/styles.css b/web/stovepipe/view/styles.css new file mode 100644 index 000000000..ef317a412 --- /dev/null +++ b/web/stovepipe/view/styles.css @@ -0,0 +1,12 @@ +@import "@submitqueue/web-platform/styles.css"; + +/* Stovepipe pages. */ +.sq-app .sq-stacked-sections { + display: grid; + gap: 38px; +} +@media (max-width: 680px) { + .sq-app .sq-stacked-sections { + gap: 20px; + } +} diff --git a/web/stovepipe/vitest.config.ts b/web/stovepipe/vitest.config.ts new file mode 100644 index 000000000..21c8ec048 --- /dev/null +++ b/web/stovepipe/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { + jsx: "automatic", + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test-setup.ts"], + }, +}); diff --git a/web/test/package/BUILD.bazel b/web/test/package/BUILD.bazel index 3ebc28ee8..f8b91f3f3 100644 --- a/web/test/package/BUILD.bazel +++ b/web/test/package/BUILD.bazel @@ -63,3 +63,15 @@ js_test( "//web:node_modules/react-dom", ], ) + +py_test( + name = "stovepipe_pack_contents_test", + srcs = ["pack_contents_test.py"], + args = [ + "$(rootpath //web/stovepipe:pkg.pack)", + "stovepipe", + ], + data = ["//web/stovepipe:pkg.pack"], + legacy_create_init = 0, + main = "pack_contents_test.py", +) diff --git a/web/test/package/pack_contents_test.py b/web/test/package/pack_contents_test.py index 89bf15e57..cbdac2d8a 100644 --- a/web/test/package/pack_contents_test.py +++ b/web/test/package/pack_contents_test.py @@ -60,11 +60,18 @@ def _imports_forbidden(specifier: str) -> bool: def main() -> int: - if len(sys.argv) != 2: + if len(sys.argv) not in (2, 3): raise RuntimeError("expected the module package archive") + profile = sys.argv[2] if len(sys.argv) == 3 else "submitqueue" + if profile not in ("submitqueue", "stovepipe"): + raise RuntimeError(f"unknown package profile: {profile}") + with tarfile.open(Path(sys.argv[1])) as archive: - missing = sorted(_EXPECTED_FILES - {member.name for member in archive.getmembers()}) + expected = _EXPECTED_FILES if profile == "submitqueue" else { + "package/dist/index.d.ts", "package/dist/server.d.ts", "package/README.md", + } + missing = sorted(expected - {member.name for member in archive.getmembers()}) if missing: print(f"The package is missing: {', '.join(missing)}", file=sys.stderr) return 1 diff --git a/web/test/package/react18/BUILD.bazel b/web/test/package/react18/BUILD.bazel index 5082432d8..ce3f5d682 100644 --- a/web/test/package/react18/BUILD.bazel +++ b/web/test/package/react18/BUILD.bazel @@ -11,6 +11,7 @@ npm_link_all_packages(name = "node_modules") ) for package, target in { "@submitqueue/web-platform": "//web/platform:pkg", + "@submitqueue/web-stovepipe": "//web/stovepipe:pkg", "@submitqueue/web-submitqueue": "//web/submitqueue:pkg", }.items() ] @@ -22,6 +23,7 @@ js_test( data = [ ":node_modules", ":node_modules/@submitqueue/web-platform", + ":node_modules/@submitqueue/web-stovepipe", ":node_modules/@submitqueue/web-submitqueue", "//web:node_modules/@bufbuild/protobuf", "//web:node_modules/@connectrpc/connect", diff --git a/web/test/package/react18/react18-runtime.mjs b/web/test/package/react18/react18-runtime.mjs index e7f39ff10..7e2a6991e 100644 --- a/web/test/package/react18/react18-runtime.mjs +++ b/web/test/package/react18/react18-runtime.mjs @@ -7,6 +7,9 @@ import { createSubmitQueueWeb } from "@submitqueue/web-submitqueue/server"; import { newHmacCursorCodec } from "@submitqueue/web-submitqueue/extension/cursor/hmac"; import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; +import { StovepipeApp } from "@submitqueue/web-stovepipe"; +import { createStovepipeWeb } from "@submitqueue/web-stovepipe/server"; + assert.match(React.version, /^18\./u); const web = createSubmitQueueWeb({ @@ -27,6 +30,17 @@ for (const route of routes) { pages.push({ route, page, html: renderToString(page) }); } +const stovepipe = createStovepipeWeb({ queues: [{ name: "repo/main" }], service: { + list: async () => ({ requests: [], nextPageToken: "" }), + getRequestHistoryByID: async () => ({ requestSummary: { requestId: "1", queue: "repo/main", changeUri: "git://repo/main/sha", baseUri: "", requestState: "accepted", stateUpdatedAtMs: 100n, acceptedAtMs: 100n, outcomeReason: "" }, events: [] }), +} }); +for (const path of ["/repo%2Fmain", "/repo%2Fmain/request/1"]) { + const result = await stovepipe.handle({ path, search: new URLSearchParams() }); + assert.equal(result.kind, "render"); + const page = createElement(StovepipeApp, { model: result.model }); + pages.push({ route: path, page, html: renderToString(page) }); +} + const dom = new JSDOM("", { url: "https://submitqueue.test/" }); for (const name of ["window", "document", "navigator"]) { Object.defineProperty(globalThis, name, { configurable: true, value: name === "window" ? dom.window : dom.window[name] });