diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index 03bda1e44..b96611069 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -99,10 +99,6 @@ export default [ path: "/actors", redirect: "/runtime/explorer", }, - { - path: "/observability", - redirect: "/runtime/observability", - }, { name: "Runtime", icon: "deploymentUnit", @@ -132,22 +128,13 @@ export default [ name: "Explorer", component: "./actors", }, - { - path: "observability", - name: "Observability", - component: "./observability", - }, ], }, { path: "/settings", name: "Settings", icon: "setting", - redirect: "/settings/console", - }, - { - path: "/settings/console", - component: "./settings/console", + component: "./settings/account", }, { path: "/", diff --git a/apps/aevatar-console-web/docs/2026-03-25-runs-page-restructure-wireframe.md b/apps/aevatar-console-web/docs/2026-03-25-runs-page-restructure-wireframe.md index 01f610b5b..3c906481d 100644 --- a/apps/aevatar-console-web/docs/2026-03-25-runs-page-restructure-wireframe.md +++ b/apps/aevatar-console-web/docs/2026-03-25-runs-page-restructure-wireframe.md @@ -32,7 +32,7 @@ ```text +-----------------------------------------------------------------------------------------------------------+ | Runtime Run Console | -| Start run | Open workflows | Open actor | Open observability | +| Start run | Open workflows | Open actor | Open settings | +-----------------------------------------------------------------------------------------------------------+ | STATUS STRIP | | [Running] [RunId: xxx] [Elapsed: 02:14] [Workflow: human_input_manual_triage] [WS] [Pending Interaction] | diff --git a/apps/aevatar-console-web/src/pages/actors/index.test.tsx b/apps/aevatar-console-web/src/pages/actors/index.test.tsx index cdb557968..a8cdb25dc 100644 --- a/apps/aevatar-console-web/src/pages/actors/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/actors/index.test.tsx @@ -41,9 +41,7 @@ describe('ActorsPage', () => { expect( screen.getByRole('button', { name: 'Open Runtime Workflows' }), ).toBeTruthy(); - expect( - screen.getByRole('button', { name: 'Open observability' }), - ).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Open observability' })).toBeNull(); expect(container.textContent).toContain('Runtime actor query'); expect(container.textContent).toContain('No recent runs yet'); expect(container.textContent).toContain( diff --git a/apps/aevatar-console-web/src/pages/actors/index.tsx b/apps/aevatar-console-web/src/pages/actors/index.tsx index 89c3b00fa..c795d6291 100644 --- a/apps/aevatar-console-web/src/pages/actors/index.tsx +++ b/apps/aevatar-console-web/src/pages/actors/index.tsx @@ -12,7 +12,6 @@ import { import { useQuery } from "@tanstack/react-query"; import { history } from "@/shared/navigation/history"; import { - buildRuntimeObservabilityHref, buildRuntimeRunsHref, buildRuntimeWorkflowsHref, } from "@/shared/navigation/runtimeRoutes"; @@ -30,7 +29,10 @@ import { Typography, } from "antd"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; +import { + runtimeActorsApi, + type ActorGraphDirection, +} from "@/shared/api/runtimeActorsApi"; import type { WorkflowActorGraphEdge, WorkflowActorGraphNode, @@ -40,10 +42,6 @@ import type { import { formatDateTime } from "@/shared/datetime/dateTime"; import { buildActorGraphElements } from "@/shared/graphs/buildGraphElements"; import GraphCanvas from "@/shared/graphs/GraphCanvas"; -import { - type ActorGraphDirection, - loadConsolePreferences, -} from "@/shared/preferences/consolePreferences"; import { loadRecentRuns } from "@/shared/runs/recentRuns"; import { codeBlockStyle, @@ -62,6 +60,7 @@ import { summaryMetricValueStyle, stretchColumnStyle, } from "@/shared/ui/proComponents"; +import { describeError } from "@/shared/ui/errorText"; import { type ActorTimelineFilters, type ActorTimelineRow, @@ -113,6 +112,11 @@ type GraphControlValues = { graphViewMode: ActorGraphViewMode; }; +const defaultActorTimelineTake = 50; +const defaultActorGraphDepth = 3; +const defaultActorGraphTake = 100; +const defaultActorGraphDirection: ActorGraphDirection = "Both"; + type SummaryFieldProps = { copyable?: boolean; label: string; @@ -373,14 +377,13 @@ function parseGraphViewMode(value: string | null): ActorGraphViewMode { } function readStateFromUrl(): ActorPageState { - const preferences = loadConsolePreferences(); if (typeof window === "undefined") { return { actorId: "", - timelineTake: preferences.actorTimelineTake, - graphDepth: preferences.actorGraphDepth, - graphTake: preferences.actorGraphTake, - graphDirection: preferences.actorGraphDirection, + timelineTake: defaultActorTimelineTake, + graphDepth: defaultActorGraphDepth, + graphTake: defaultActorGraphTake, + graphDirection: defaultActorGraphDirection, edgeTypes: [], }; } @@ -390,19 +393,19 @@ function readStateFromUrl(): ActorPageState { actorId: params.get("actorId") ?? "", timelineTake: parsePositiveInt( params.get("timelineTake"), - preferences.actorTimelineTake + defaultActorTimelineTake ), graphDepth: parsePositiveInt( params.get("graphDepth"), - preferences.actorGraphDepth + defaultActorGraphDepth ), graphTake: parsePositiveInt( params.get("graphTake"), - preferences.actorGraphTake + defaultActorGraphTake ), graphDirection: parseDirection( params.get("graphDirection"), - preferences.actorGraphDirection + defaultActorGraphDirection ), edgeTypes: params .getAll("edgeTypes") @@ -848,17 +851,6 @@ const ActorsPage: React.FC = () => { - } > @@ -1149,7 +1141,7 @@ const ActorsPage: React.FC = () => { showIcon type="error" title="Failed to load timeline" - description={String(timelineQuery.error)} + description={describeError(timelineQuery.error)} /> ) : null} @@ -1274,7 +1266,7 @@ const ActorsPage: React.FC = () => { showIcon type="error" title="Failed to load graph topology" - description={String(currentGraphError)} + description={describeError(currentGraphError)} /> ) : currentGraph && currentGraph.nodes.length > 0 ? (
@@ -1337,7 +1329,7 @@ const ActorsPage: React.FC = () => { showIcon type="error" title="Failed to load actor" - description={String(snapshotQuery.error)} + description={describeError(snapshotQuery.error)} /> ) : snapshotRecord ? ( <> @@ -1561,7 +1553,7 @@ const ActorsPage: React.FC = () => { showIcon type="error" title="Failed to load graph view" - description={String(currentGraphError)} + description={describeError(currentGraphError)} /> ) : graphSummary ? ( <> diff --git a/apps/aevatar-console-web/src/pages/auth/callback/index.tsx b/apps/aevatar-console-web/src/pages/auth/callback/index.tsx index 4d03f3a53..622bca18c 100644 --- a/apps/aevatar-console-web/src/pages/auth/callback/index.tsx +++ b/apps/aevatar-console-web/src/pages/auth/callback/index.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { NyxIDAuthClient } from '@/shared/auth/client'; import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { loadStoredAuthSession } from '@/shared/auth/session'; +import { describeError } from '@/shared/ui/errorText'; const CallbackPage: React.FC = () => { const [errorText, setErrorText] = useState(undefined); @@ -26,7 +27,7 @@ const CallbackPage: React.FC = () => { return; } - setErrorText(error instanceof Error ? error.message : String(error)); + setErrorText(describeError(error)); } }; diff --git a/apps/aevatar-console-web/src/pages/login/index.tsx b/apps/aevatar-console-web/src/pages/login/index.tsx index 0e6a7c88e..b27325f0d 100644 --- a/apps/aevatar-console-web/src/pages/login/index.tsx +++ b/apps/aevatar-console-web/src/pages/login/index.tsx @@ -11,6 +11,7 @@ import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { sanitizeReturnTo, } from '@/shared/auth/session'; +import { describeError } from '@/shared/ui/errorText'; const pageStyle: React.CSSProperties = { minHeight: '100vh', @@ -79,7 +80,7 @@ const LoginPage: React.FC = () => { }); } catch (error) { setPending(false); - setErrorText(error instanceof Error ? error.message : String(error)); + setErrorText(describeError(error)); } }; diff --git a/apps/aevatar-console-web/src/pages/observability/index.test.tsx b/apps/aevatar-console-web/src/pages/observability/index.test.tsx deleted file mode 100644 index 699c838b2..000000000 --- a/apps/aevatar-console-web/src/pages/observability/index.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { screen } from "@testing-library/react"; -import React from "react"; -import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; -import ObservabilityPage from "./index"; - -describe("ObservabilityPage", () => { - beforeEach(() => { - window.history.replaceState( - null, - "", - "/observability?workflow=direct&actorId=Workflow:19fe1b04&commandId=cmd-123" - ); - }); - - it("renders platform-oriented observability jumps", async () => { - const { container } = renderWithQueryClient( - React.createElement(ObservabilityPage) - ); - - expect(container.textContent).toContain("Observability"); - expect(container.textContent).toContain( - "Use configured external tools as the jump hub for runtime, scopes, raw platform services, platform governance, and local settings without adding new backend APIs." - ); - expect(container.textContent).toContain("Console surfaces"); - expect(container.textContent).toContain("Open Runtime Explorer"); - expect(container.textContent).toContain("Open Console Settings"); - expect(container.textContent).toContain("Open Scopes"); - expect(container.textContent).toContain("Open Platform Services"); - expect(container.textContent).toContain("Open Platform Governance"); - expect(screen.getByText("direct")).toBeTruthy(); - expect(screen.getByText("Workflow:19fe1b04")).toBeTruthy(); - expect(screen.getByText("cmd-123")).toBeTruthy(); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/observability/index.tsx b/apps/aevatar-console-web/src/pages/observability/index.tsx deleted file mode 100644 index c15a67be4..000000000 --- a/apps/aevatar-console-web/src/pages/observability/index.tsx +++ /dev/null @@ -1,501 +0,0 @@ -import { - PageContainer, - ProCard, - ProDescriptions, - ProForm, - ProFormText, -} from "@ant-design/pro-components"; -import type { - ProDescriptionsItemProps, - ProFormInstance, -} from "@ant-design/pro-components"; -import { history } from "@/shared/navigation/history"; -import { - buildRuntimeExplorerHref, - buildRuntimeRunsHref, - buildRuntimeWorkflowsHref, -} from "@/shared/navigation/runtimeRoutes"; -import { Alert, Button, Col, Empty, Row, Space, Tag, Typography } from "antd"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { - buildObservabilityTargets, - type ObservabilityContext, - type ObservabilityTarget, -} from "@/shared/observability/observabilityLinks"; -import { loadConsolePreferences } from "@/shared/preferences/consolePreferences"; -import { - cardListActionStyle, - cardListHeaderStyle, - cardListItemStyle, - cardListMainStyle, - cardListStyle, - cardListUrlStyle, - compactPanelHeight, - fillCardStyle, - moduleCardProps, - scrollViewportBodyStyle, - scrollViewportStyle, - stretchColumnStyle, -} from "@/shared/ui/proComponents"; - -type ObservabilityContextForm = ObservabilityContext; - -type ObservabilitySummaryRecord = { - configuredCount: number; - missingCount: number; - workflow: string; - actorId: string; - commandId: string; -}; - -type InternalJumpItem = { - id: string; - title: string; - description: string; - href: string; - enabled: boolean; -}; - -const consoleSurfacesCardStyle = { - ...fillCardStyle, - height: compactPanelHeight, -} as const; - -const configuredTargetsCardStyle = { - ...fillCardStyle, - height: compactPanelHeight, -} as const; - -const consoleSurfacesBodyStyle = scrollViewportBodyStyle; - -const configuredTargetsBodyStyle = scrollViewportBodyStyle; - -const consoleSurfacesViewportStyle = scrollViewportStyle; - -const configuredTargetsViewportStyle = scrollViewportStyle; - -const cardListMetaWrapStyle: React.CSSProperties = { - display: "flex", - flexWrap: "wrap", - gap: 8, -}; - -const summaryColumns: ProDescriptionsItemProps[] = [ - { - title: "Configured targets", - dataIndex: "configuredCount", - valueType: "digit", - }, - { - title: "Missing targets", - dataIndex: "missingCount", - valueType: "digit", - }, - { - title: "Workflow context", - dataIndex: "workflow", - render: (_, record) => record.workflow || "n/a", - }, - { - title: "Actor context", - dataIndex: "actorId", - render: (_, record) => record.actorId || "n/a", - }, - { - title: "Command context", - dataIndex: "commandId", - render: (_, record) => record.commandId || "n/a", - }, -]; - -function readContextFromUrl(): ObservabilityContext { - if (typeof window === "undefined") { - return { - workflow: "", - actorId: "", - commandId: "", - runId: "", - stepId: "", - }; - } - - const params = new URLSearchParams(window.location.search); - return { - workflow: params.get("workflow") ?? "", - actorId: params.get("actorId") ?? "", - commandId: params.get("commandId") ?? "", - runId: params.get("runId") ?? "", - stepId: params.get("stepId") ?? "", - }; -} - -function renderConfiguredTargetCards( - targets: ObservabilityTarget[] -): React.ReactNode { - if (targets.length === 0) { - return ( - - ); - } - - return ( -
- {targets.map((record) => ( -
-
-
- - {record.label} - - {record.status} - - - - {record.description} - -
-
- -
- {record.contextSummary} - - {record.status === "configured" - ? "External target ready" - : "No URL configured"} - -
- - {record.homeUrl ? ( - - {record.homeUrl} - - ) : ( - No URL configured. - )} - -
- - -
-
- ))} -
- ); -} - -function renderInternalJumpCards( - internalJumps: InternalJumpItem[] -): React.ReactNode { - if (internalJumps.length === 0) { - return ( - - ); - } - - return ( -
- {internalJumps.map((record) => ( -
-
-
- - {record.title} - - {record.enabled ? "Ready" : "Missing context"} - - - - {record.description} - -
-
- -
- -
-
- ))} -
- ); -} - -const ObservabilityPage: React.FC = () => { - const preferences = useMemo(() => loadConsolePreferences(), []); - const initialContext = useMemo(() => readContextFromUrl(), []); - const formRef = useRef | undefined>( - undefined - ); - const [context, setContext] = useState(initialContext); - - const targets = useMemo( - () => buildObservabilityTargets(preferences, context), - [context, preferences] - ); - - const summaryRecord = useMemo( - () => ({ - configuredCount: targets.filter( - (target) => target.status === "configured" - ).length, - missingCount: targets.filter((target) => target.status === "missing") - .length, - workflow: context.workflow, - actorId: context.actorId, - commandId: context.commandId, - }), - [context.actorId, context.commandId, context.workflow, targets] - ); - - const internalJumps = useMemo( - () => [ - { - id: "jump-runs", - title: "Open Runtime Runs", - description: context.workflow - ? `Open Runtime Runs with workflow=${context.workflow}.` - : "Open Runtime Runs and keep the current workflow selection manual.", - href: buildRuntimeRunsHref({ - workflow: context.workflow || undefined, - }), - enabled: true, - }, - { - id: "jump-actors", - title: "Open Runtime Explorer", - description: context.actorId - ? `Open Runtime Explorer with actorId=${context.actorId}.` - : "Provide actorId first to jump directly to Runtime Explorer.", - href: buildRuntimeExplorerHref({ - actorId: context.actorId || undefined, - }), - enabled: Boolean(context.actorId), - }, - { - id: "jump-workflows", - title: "Open Runtime Workflows", - description: context.workflow - ? `Open Runtime Workflows with workflow=${context.workflow}.` - : "Provide workflow first to jump directly to Runtime Workflows.", - href: buildRuntimeWorkflowsHref({ - workflow: context.workflow || undefined, - }), - enabled: Boolean(context.workflow), - }, - { - id: "jump-settings", - title: "Open Console Settings", - description: - "Manage observability endpoint URLs and console preferences.", - href: "/settings/console", - enabled: true, - }, - { - id: "jump-scopes", - title: "Open Scopes", - description: - "Inspect published workflow and script assets owned by GAgentService scopes without exposing platform tenant/app identity.", - href: "/scopes", - enabled: true, - }, - { - id: "jump-services", - title: "Open Platform Services", - description: - "Inspect raw platform services, revisions, serving targets, rollouts, and traffic exposure.", - href: "/services", - enabled: true, - }, - { - id: "jump-governance", - title: "Open Platform Governance", - description: - "Inspect raw bindings, policies, endpoint exposure, and activation capability views.", - href: "/governance", - enabled: true, - }, - ], - [context.actorId, context.workflow] - ); - - useEffect(() => { - if (typeof window === "undefined") { - return; - } - - const url = new URL(window.location.href); - const entries = Object.entries(context) as Array< - [keyof ObservabilityContext, string] - >; - for (const [key, value] of entries) { - if (value) { - url.searchParams.set(key, value); - } else { - url.searchParams.delete(key); - } - } - window.history.replaceState(null, "", `${url.pathname}${url.search}`); - }, [context]); - - return ( - - - - - - - - formRef={formRef} - layout="vertical" - initialValues={initialContext} - onFinish={async (values) => { - setContext({ - workflow: values.workflow?.trim() ?? "", - actorId: values.actorId?.trim() ?? "", - commandId: values.commandId?.trim() ?? "", - runId: values.runId?.trim() ?? "", - stepId: values.stepId?.trim() ?? "", - }); - return true; - }} - submitter={{ - render: (props) => ( - - - - - ), - }} - > - - - - - - - - - - - - - column={2} - dataSource={summaryRecord} - columns={summaryColumns} - /> - - - - - - - -
- {renderConfiguredTargetCards(targets)} -
-
- - - - -
- {renderInternalJumpCards(internalJumps)} -
-
- -
-
- ); -}; - -export default ObservabilityPage; diff --git a/apps/aevatar-console-web/src/pages/overview/index.test.tsx b/apps/aevatar-console-web/src/pages/overview/index.test.tsx index b0476429c..3c3eb564a 100644 --- a/apps/aevatar-console-web/src/pages/overview/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/overview/index.test.tsx @@ -33,7 +33,7 @@ describe("OverviewPage", () => { expect(container.textContent).toContain("Overview"); expect(container.textContent).toContain( - "Overview of runtime workflows, scope assets, raw platform services, platform governance, actors, and observability." + "Overview of runtime workflows, scope assets, raw platform services, platform governance, and actors." ); expect(container.textContent).toContain("Quick actions"); expect(container.textContent).toContain("Platform entry points"); @@ -42,7 +42,10 @@ describe("OverviewPage", () => { expect(container.textContent).toContain("Platform services"); expect(container.textContent).toContain("Platform governance"); expect(container.textContent).toContain("Open Runtime Explorer"); - expect(container.textContent).toContain("Open Runtime Observability"); + expect(container.textContent).toContain("Start direct workflow"); + expect(container.textContent).not.toContain("Start preferred workflow"); + expect(container.textContent).not.toContain("Preferred workflow"); + expect(container.textContent).not.toContain("Open Runtime Observability"); expect(container.textContent).not.toContain("Open Studio"); await waitFor(() => { expect(runtimeCatalogApi.listWorkflowNames).toHaveBeenCalled(); diff --git a/apps/aevatar-console-web/src/pages/overview/index.tsx b/apps/aevatar-console-web/src/pages/overview/index.tsx index 5f19e78e6..3b000e019 100644 --- a/apps/aevatar-console-web/src/pages/overview/index.tsx +++ b/apps/aevatar-console-web/src/pages/overview/index.tsx @@ -13,7 +13,6 @@ import React, { useMemo } from "react"; import { history } from "@/shared/navigation/history"; import { buildRuntimeExplorerHref, - buildRuntimeObservabilityHref, buildRuntimePrimitivesHref, buildRuntimeRunsHref, buildRuntimeWorkflowsHref, @@ -24,8 +23,6 @@ import { cardListHeaderStyle, cardListItemStyle, cardListMainStyle, - cardListStyle, - cardListUrlStyle, cardStackStyle, embeddedPanelStyle, fillCardStyle, @@ -38,7 +35,7 @@ import { summaryMetricValueStyle, stretchColumnStyle, } from "@/shared/ui/proComponents"; -import type { ObservabilityOverviewItem } from "./useOverviewData"; +import { describeError } from "@/shared/ui/errorText"; import { useOverviewData } from "./useOverviewData"; type CapabilitySurfaceItem = { @@ -111,89 +108,12 @@ const SummaryMetric: React.FC = ({ label, value }) => (
); -function renderObservabilityTargetCards( - observabilityTargets: ObservabilityOverviewItem[], - preferredWorkflow: string, -): React.ReactNode { - if (observabilityTargets.length === 0) { - return ( - - No observability targets configured. - - ); - } - - return ( -
- {observabilityTargets.map((record) => ( -
-
-
- - {record.label} - - {record.status} - - - - {record.description} - -
-
- - {record.homeUrl ? ( - - {record.homeUrl} - - ) : ( - No URL configured. - )} - -
- - -
-
- ))} -
- ); -} - const OverviewPage: React.FC = () => { const { agentsQuery, capabilitiesQuery, - configuredObservabilityCount, - grafanaBaseUrl, humanFocusedWorkflows, liveActors, - observabilityTargets, - preferences, - profileData, visibleCatalogItems, workflowsQuery, capabilityConnectorSummary, @@ -240,20 +160,6 @@ const OverviewPage: React.FC = () => { actionLabel: "Open Runtime Explorer", onOpen: () => history.push(buildRuntimeExplorerHref()), }, - { - id: "surface-observability", - title: "Observability", - summary: `${configuredObservabilityCount}/${observabilityTargets.length} targets configured`, - description: - "Drive Grafana, Jaeger, Loki, and other external tools with the current runtime context.", - actionLabel: "Open Runtime Observability", - onOpen: () => - history.push( - buildRuntimeObservabilityHref({ - workflow: preferences.preferredWorkflow, - }) - ), - }, { id: "surface-scopes", title: "Scope assets", @@ -284,9 +190,6 @@ const OverviewPage: React.FC = () => { ], [ agentsQuery.data?.length, - configuredObservabilityCount, - observabilityTargets.length, - preferences.preferredWorkflow, capabilitiesQuery.data?.primitives.length, visibleCatalogItems.length, ] @@ -294,15 +197,10 @@ const OverviewPage: React.FC = () => { const platformQuickActions = useMemo( () => [ { - id: "quick-start-preferred", - label: "Start preferred workflow", + id: "quick-start-direct", + label: "Start direct workflow", primary: true, - onOpen: () => - history.push( - buildRuntimeRunsHref({ - workflow: preferences.preferredWorkflow, - }) - ), + onOpen: () => history.push(buildRuntimeRunsHref({ workflow: "direct" })), }, { id: "quick-workflows", @@ -324,16 +222,6 @@ const OverviewPage: React.FC = () => { label: "Open Runtime Explorer", onOpen: () => history.push(buildRuntimeExplorerHref()), }, - { - id: "quick-observability", - label: "Open Runtime Observability", - onOpen: () => - history.push( - buildRuntimeObservabilityHref({ - workflow: preferences.preferredWorkflow, - }) - ), - }, { id: "quick-scopes", label: "Open scopes", @@ -350,33 +238,23 @@ const OverviewPage: React.FC = () => { onOpen: () => history.push("/governance"), }, ], - [preferences.preferredWorkflow] + [] ); const localQuickActions = useMemo( - () => - [ - { - id: "quick-console-settings", - label: "Open console settings", - onOpen: () => history.push("/settings/console"), - }, - grafanaBaseUrl - ? { - id: "quick-grafana-explore", - label: "Open Grafana Explore", - href: `${grafanaBaseUrl}/explore`, - target: "_blank", - rel: "noreferrer", - } - : null, - ].filter(Boolean) as QuickActionItem[], - [grafanaBaseUrl] + () => [ + { + id: "quick-console-settings", + label: "Open settings", + onOpen: () => history.push("/settings"), + }, + ], + [] ); return ( @@ -411,34 +289,6 @@ const OverviewPage: React.FC = () => { /> - - - - Preferred workflow - {preferences.preferredWorkflow} - - - - - - - Observability - {grafanaBaseUrl ? ( - - ) : ( - Not configured - )} - - - @@ -476,8 +326,7 @@ const OverviewPage: React.FC = () => { type="secondary" style={{ display: "block", marginTop: 4 }} > - Jump into browser-level preferences, local runtime - configuration, and external observability tools. + Jump into account settings and local console entry points. @@ -543,39 +392,21 @@ const OverviewPage: React.FC = () => {
- - {profileData.preferredWorkflow} - {profileData.observability} - -
- - +
- - {grafanaBaseUrl ? ( - - ) : null}
@@ -664,7 +495,7 @@ const OverviewPage: React.FC = () => { showIcon type="error" title="Failed to load capability digest" - description={String(capabilitiesQuery.error)} + description={describeError(capabilitiesQuery.error)} /> ) : (
@@ -744,20 +575,6 @@ const OverviewPage: React.FC = () => { - - - - {renderObservabilityTargetCards( - observabilityTargets, - preferences.preferredWorkflow, - )} - - - ); }; diff --git a/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts b/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts index b17198cce..7616c064e 100644 --- a/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts +++ b/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts @@ -2,29 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import React, { useEffect, useMemo, useState } from "react"; import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; -import { buildObservabilityTargets } from "@/shared/observability/observabilityLinks"; -import { loadConsolePreferences } from "@/shared/preferences/consolePreferences"; import { listVisibleWorkflowCatalogItems } from "@/shared/workflows/catalogVisibility"; -export type ConsoleProfileItem = { - preferredWorkflow: string; - observability: string; -}; - -export type ObservabilityOverviewItem = { - id: string; - label: string; - description: string; - status: "configured" | "missing"; - homeUrl: string; -}; - -function normalizeBaseUrl(value: string): string { - return value.trim().replace(/\/+$/, ""); -} - export function useOverviewData() { - const preferences = useMemo(() => loadConsolePreferences(), []); const [deferredDetailsEnabled, setDeferredDetailsEnabled] = useState(false); useEffect(() => { @@ -132,52 +112,12 @@ export function useOverviewData() { [agentsQuery.data] ); - const grafanaBaseUrl = normalizeBaseUrl(preferences.grafanaBaseUrl); - - const profileData = useMemo( - () => ({ - preferredWorkflow: preferences.preferredWorkflow, - observability: grafanaBaseUrl ? "Configured" : "Not configured", - }), - [grafanaBaseUrl, preferences.preferredWorkflow] - ); - - const observabilityTargets = useMemo( - () => - buildObservabilityTargets(preferences, { - workflow: preferences.preferredWorkflow, - actorId: "", - commandId: "", - runId: "", - stepId: "", - }).map((target) => ({ - id: target.id, - label: target.label, - description: target.description, - status: target.status, - homeUrl: target.homeUrl, - })), - [preferences] - ); - - const configuredObservabilityCount = useMemo( - () => - observabilityTargets.filter((target) => target.status === "configured") - .length, - [observabilityTargets] - ); - return { agentsQuery, capabilitiesQuery, catalogQuery, - configuredObservabilityCount, - grafanaBaseUrl, humanFocusedWorkflows, liveActors, - observabilityTargets, - preferences, - profileData, visibleCatalogItems, workflowsQuery, capabilityConnectorSummary, diff --git a/apps/aevatar-console-web/src/pages/primitives/index.tsx b/apps/aevatar-console-web/src/pages/primitives/index.tsx index 2c14e4b86..9bc02da4d 100644 --- a/apps/aevatar-console-web/src/pages/primitives/index.tsx +++ b/apps/aevatar-console-web/src/pages/primitives/index.tsx @@ -48,6 +48,7 @@ import { summaryMetricValueStyle, stretchColumnStyle, } from "@/shared/ui/proComponents"; +import { describeError } from "@/shared/ui/errorText"; type PrimitiveLibraryRow = WorkflowPrimitiveDescriptor & { key: string; @@ -369,7 +370,7 @@ const PrimitivesPage: React.FC = () => { showIcon type="error" title="Failed to load primitive library" - description={String(primitivesQuery.error)} + description={describeError(primitivesQuery.error)} /> ) : !selectedPrimitive ? ( { screen.getByRole("button", { name: "Open Runtime Explorer" }) ).toBeTruthy(); expect( - screen.getByRole("button", { name: "Open observability hub" }) - ).toBeTruthy(); + screen.queryByRole("button", { name: "Open observability hub" }) + ).toBeNull(); expect(screen.getByRole("button", { name: "Inspector" })).toBeTruthy(); expect(container.textContent).toContain("Launch rail"); expect(container.textContent).toContain("Run trace"); diff --git a/apps/aevatar-console-web/src/pages/runs/index.tsx b/apps/aevatar-console-web/src/pages/runs/index.tsx index d8fc1b736..c8189b997 100644 --- a/apps/aevatar-console-web/src/pages/runs/index.tsx +++ b/apps/aevatar-console-web/src/pages/runs/index.tsx @@ -23,7 +23,6 @@ import { useQuery } from "@tanstack/react-query"; import { history } from "@/shared/navigation/history"; import { buildRuntimeExplorerHref, - buildRuntimeObservabilityHref, buildRuntimeWorkflowsHref, } from "@/shared/navigation/runtimeRoutes"; import { @@ -53,7 +52,6 @@ import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; import { formatDateTime } from "@/shared/datetime/dateTime"; -import { loadConsolePreferences } from "@/shared/preferences/consolePreferences"; import { clearRecentRuns, loadRecentRuns, @@ -96,6 +94,7 @@ import { composerRailDefaultWidth, composerRailKeyboardStep, type ConsoleViewKey, + defaultRunRouteName, formatElapsedDuration, humanInputColumns, type HumanInputRecord, @@ -173,12 +172,8 @@ function resolveRequestedServiceId( } const RunsPage: React.FC = () => { - const preferences = useMemo(() => loadConsolePreferences(), []); const [messageApi, messageContextHolder] = message.useMessage(); - const urlInitialFormValues = useMemo( - () => readInitialRunFormValues(preferences.preferredWorkflow), - [preferences.preferredWorkflow] - ); + const urlInitialFormValues = useMemo(() => readInitialRunFormValues(), []); const draftRunKey = useMemo(() => { if (typeof window === "undefined") { return ""; @@ -240,7 +235,7 @@ const RunsPage: React.FC = () => { scopeDraftPayload?.bundleName ?? (endpointInvocationDraftPayload ? "" : undefined) ?? initialFormValues.routeName ?? - preferences.preferredWorkflow + defaultRunRouteName ); const [recentRuns, setRecentRuns] = useState(() => loadRecentRuns() @@ -1356,20 +1351,6 @@ const RunsPage: React.FC = () => { > Open Runtime Explorer -
{ + beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); + }); + + it("renders signed-in account details", async () => { + persistAuthSession({ + tokens: { + accessToken: "token", + tokenType: "Bearer", + expiresIn: 3600, + expiresAt: Date.now() + 60_000, + }, + user: { + sub: "user-123", + email: "ada@example.com", + email_verified: true, + name: "Ada Lovelace", + roles: ["admin", "operator"], + groups: ["platform"], + }, + }); + + renderWithQueryClient(React.createElement(AccountSettingsPage)); + + expect(await screen.findByText("Settings")).toBeTruthy(); + expect(screen.getByText("Account profile")).toBeTruthy(); + expect(screen.getAllByText("Ada Lovelace")).toHaveLength(2); + expect(screen.getAllByText("ada@example.com")).toHaveLength(2); + expect(screen.getByText("Session summary")).toBeTruthy(); + expect(screen.getByText("Access notes")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy(); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/settings/account.tsx b/apps/aevatar-console-web/src/pages/settings/account.tsx new file mode 100644 index 000000000..41b92adc5 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/account.tsx @@ -0,0 +1,246 @@ +import { UserOutlined } from "@ant-design/icons"; +import { history } from "@/shared/navigation/history"; +import { buildRuntimeRunsHref } from "@/shared/navigation/runtimeRoutes"; +import { clearStoredAuthSession, loadRestorableAuthSession } from "@/shared/auth/session"; +import { + cardStackStyle, + fillCardStyle, + moduleCardProps, + summaryFieldGridStyle, + summaryMetricGridStyle, + stretchColumnStyle, +} from "@/shared/ui/proComponents"; +import { Avatar, Button, Col, Row, Space, Tag, Tooltip, Typography } from "antd"; +import React, { useMemo } from "react"; +import { ProCard } from "@ant-design/pro-components"; +import { SettingsPageShell, SummaryField, SummaryMetric } from "./shared"; + +const accountUsageNotes = [ + { + id: "account-session", + text: "This page reflects the active NyxID session restored from the browser before authenticated console requests are sent.", + }, + { + id: "account-signout", + text: "Signing out clears the stored browser session and returns the console to the login screen.", + }, +]; + +const compactIdentityStyle: React.CSSProperties = { + margin: 0, + maxWidth: "100%", +}; + +function formatCompactIdentifier( + value: string, + leading = 8, + trailing = 6 +): string { + if (value.length <= leading + trailing + 3) { + return value; + } + + return `${value.slice(0, leading)}...${value.slice(-trailing)}`; +} + +const AccountSettingsPage: React.FC = () => { + const authSession = useMemo(() => loadRestorableAuthSession(), []); + + const accountDisplayName = useMemo( + () => + authSession?.user.name || + authSession?.user.email || + authSession?.user.sub || + "No active session", + [authSession] + ); + const accountSecondaryText = useMemo(() => { + if (!authSession) { + return "No signed-in user information is available in this browser session."; + } + + return authSession.user.email || authSession.user.sub; + }, [authSession]); + const rolesLabel = useMemo(() => { + const roles = authSession?.user.roles ?? []; + return roles.length > 0 ? roles.join(", ") : "n/a"; + }, [authSession]); + const groupsLabel = useMemo(() => { + const groups = authSession?.user.groups ?? []; + return groups.length > 0 ? groups.join(", ") : "n/a"; + }, [authSession]); + const compactUserId = useMemo( + () => + authSession?.user.sub + ? formatCompactIdentifier(authSession.user.sub) + : "n/a", + [authSession] + ); + + const handleSignOut = () => { + clearStoredAuthSession(); + window.location.replace("/login"); + }; + + return ( + + + + +
+ {authSession ? ( + <> + + } + size={56} + src={authSession.user.picture} + /> +
+ + {accountDisplayName} + + + {accountSecondaryText} + +
+
+ + + Signed in + {authSession.user.email_verified ? ( + Email verified + ) : ( + Email unverified + )} + {authSession.user.roles?.length ? ( + {`${authSession.user.roles.length} roles`} + ) : null} + {authSession.user.groups?.length ? ( + {`${authSession.user.groups.length} groups`} + ) : null} + + +
+ + + + {compactUserId} + + + } + /> + + +
+ + + + + + + ) : ( +
+ + {accountSecondaryText} + + + + +
+ )} +
+
+ + + + + +
+
+ + +
+ +
+ + +
+
+
+ + + {accountUsageNotes.map((item) => ( + {item.text} + ))} + + +
+ +
+
+ ); +}; + +export default AccountSettingsPage; diff --git a/apps/aevatar-console-web/src/pages/settings/console.test.tsx b/apps/aevatar-console-web/src/pages/settings/console.test.tsx deleted file mode 100644 index 90cf129f4..000000000 --- a/apps/aevatar-console-web/src/pages/settings/console.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { screen } from "@testing-library/react"; -import React from "react"; -import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; -import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; -import ConsoleSettingsPage from "./console"; - -jest.mock("@/shared/api/runtimeCatalogApi", () => ({ - runtimeCatalogApi: { - listWorkflowCatalog: jest.fn(async () => [ - { - name: "incident_triage", - description: "Incident triage", - category: "ops", - group: "starter", - groupLabel: "Starter", - sortOrder: 1, - source: "home", - sourceLabel: "Saved", - showInLibrary: true, - isPrimitiveExample: false, - requiresLlmProvider: true, - primitives: ["llm_call"], - }, - ]), - }, -})); - -describe("ConsoleSettingsPage", () => { - beforeEach(() => { - window.localStorage.clear(); - jest.clearAllMocks(); - }); - - it("renders console preference sections on the dedicated settings page", async () => { - renderWithQueryClient(React.createElement(ConsoleSettingsPage)); - - expect(await screen.findByText("Console preferences")).toBeTruthy(); - expect(screen.getByText("Workflow defaults")).toBeTruthy(); - expect(screen.getByText("Observability URLs")).toBeTruthy(); - expect(screen.getByText("Runtime explorer defaults")).toBeTruthy(); - expect(runtimeCatalogApi.listWorkflowCatalog).toHaveBeenCalled(); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/settings/console.tsx b/apps/aevatar-console-web/src/pages/settings/console.tsx deleted file mode 100644 index 3d8595dd9..000000000 --- a/apps/aevatar-console-web/src/pages/settings/console.tsx +++ /dev/null @@ -1,476 +0,0 @@ -import type { ProFormInstance } from "@ant-design/pro-components"; -import { - PageContainer, - ProCard, - ProForm, - ProFormDigit, - ProFormSelect, - ProFormText, -} from "@ant-design/pro-components"; -import { useQuery } from "@tanstack/react-query"; -import { history } from "@/shared/navigation/history"; -import { - buildRuntimeObservabilityHref, - buildRuntimeRunsHref, -} from "@/shared/navigation/runtimeRoutes"; -import { Button, Col, Empty, Row, Space, Tag, Typography, message } from "antd"; -import React, { useMemo, useRef, useState } from "react"; -import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; -import { - buildObservabilityTargets, - type ObservabilityTarget, -} from "@/shared/observability/observabilityLinks"; -import { - type ActorGraphDirection, - type ConsolePreferences, - loadConsolePreferences, - resetConsolePreferences, - saveConsolePreferences, -} from "@/shared/preferences/consolePreferences"; -import { buildWorkflowCatalogOptions } from "@/shared/workflows/catalogVisibility"; -import { - cardListActionStyle, - cardListHeaderStyle, - cardListItemStyle, - cardListMainStyle, - cardListStyle, - cardListUrlStyle, - cardStackStyle, - fillCardStyle, - moduleCardProps, - summaryFieldGridStyle, - summaryFieldLabelStyle, - summaryFieldStyle, - summaryMetricGridStyle, - summaryMetricStyle, - summaryMetricValueStyle, - stretchColumnStyle, -} from "@/shared/ui/proComponents"; - -type ConsoleSettingsSummaryRecord = { - preferredWorkflow: string; - graphDirection: ActorGraphDirection; - observabilityTargetsConfigured: number; -}; - -const consoleUsageNotes = [ - { - id: "console-defaults", - text: "These settings are stored locally in the browser and apply to console navigation, runtime explorer defaults, and outbound observability links.", - }, - { - id: "console-observability", - text: "Grafana, Jaeger, and Loki URLs are not proxied. The console only builds outbound links and preserves current workflow context.", - }, -]; - -type SummaryFieldProps = { - label: string; - value: React.ReactNode; -}; - -type SummaryMetricProps = { - label: string; - value: React.ReactNode; -}; - -const SummaryField: React.FC = ({ label, value }) => ( -
- {label} - {value} -
-); - -const SummaryMetric: React.FC = ({ label, value }) => ( -
- {label} - {value} -
-); - -function renderObservabilityEndpointCards( - observabilityTargets: ObservabilityTarget[], -): React.ReactNode { - if (observabilityTargets.length === 0) { - return ( - - No observability targets configured. - - ); - } - - return ( -
- {observabilityTargets.map((record) => ( -
-
-
- - {record.label} - - {record.status} - - - - {record.description} - -
-
- - {record.homeUrl ? ( - - {record.homeUrl} - - ) : ( - No URL configured. - )} - -
- - -
-
- ))} -
- ); -} - -const ConsoleSettingsPage: React.FC = () => { - const formRef = useRef | undefined>( - undefined - ); - const [messageApi, messageContextHolder] = message.useMessage(); - const [preferences, setPreferences] = useState( - loadConsolePreferences() - ); - - const workflowCatalogQuery = useQuery({ - queryKey: ["settings-console", "workflow-catalog"], - queryFn: () => runtimeCatalogApi.listWorkflowCatalog(), - }); - - const workflowOptions = useMemo( - () => - buildWorkflowCatalogOptions( - workflowCatalogQuery.data ?? [], - preferences.preferredWorkflow - ), - [preferences.preferredWorkflow, workflowCatalogQuery.data] - ); - - const observabilityTargets = useMemo( - () => - buildObservabilityTargets(preferences, { - workflow: preferences.preferredWorkflow, - actorId: "", - commandId: "", - runId: "", - stepId: "", - }), - [preferences] - ); - - const summaryRecord = useMemo( - () => ({ - preferredWorkflow: preferences.preferredWorkflow, - graphDirection: preferences.actorGraphDirection, - observabilityTargetsConfigured: observabilityTargets.filter( - (target) => target.status === "configured" - ).length, - }), - [observabilityTargets, preferences] - ); - - const handleSavePreferences = async (values: ConsolePreferences) => { - const next = saveConsolePreferences(values); - setPreferences(next); - messageApi.success("Console preferences saved."); - return true; - }; - - const handleResetPreferences = () => { - const next = resetConsolePreferences(); - setPreferences(next); - formRef.current?.setFieldsValue(next); - messageApi.success("Console preferences reset to defaults."); - }; - - return ( - history.push("/overview")} - extra={[ - , - ]} - > - {messageContextHolder} - - - - - formRef={formRef} - layout="vertical" - initialValues={preferences} - onFinish={handleSavePreferences} - submitter={{ - render: (props) => ( - - - - - - ), - }} - > - - - - - - Loading workflows... - - ) : ( - - ), - }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - name="actorGraphDirection" - label="Actor graph direction" - options={[ - { label: "Both", value: "Both" }, - { label: "Outbound", value: "Outbound" }, - { label: "Inbound", value: "Inbound" }, - ]} - rules={[ - { - required: true, - message: "Graph direction is required.", - }, - ]} - /> - - - - - - - - - - - -
- - {summaryRecord.preferredWorkflow} - {summaryRecord.graphDirection} - - -
- - -
- -
- - - - -
-
-
- - {renderObservabilityEndpointCards(observabilityTargets)} - - - - {consoleUsageNotes.map((item) => ( - {item.text} - ))} - - -
- -
-
- ); -}; - -export default ConsoleSettingsPage; diff --git a/apps/aevatar-console-web/src/pages/settings/shared.tsx b/apps/aevatar-console-web/src/pages/settings/shared.tsx new file mode 100644 index 000000000..ff40eb590 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/settings/shared.tsx @@ -0,0 +1,60 @@ +import { PageContainer } from "@ant-design/pro-components"; +import { history } from "@/shared/navigation/history"; +import { Typography } from "antd"; +import React from "react"; +import { + summaryFieldLabelStyle, + summaryFieldStyle, + summaryMetricStyle, + summaryMetricValueStyle, +} from "@/shared/ui/proComponents"; + +type SettingsPageShellProps = { + children: React.ReactNode; + content: string; +}; + +type SummaryFieldProps = { + label: string; + value: React.ReactNode; +}; + +type SummaryMetricProps = { + label: string; + value: React.ReactNode; +}; + +function renderSummaryFieldValue(value: React.ReactNode): React.ReactNode { + if (typeof value === "string" || typeof value === "number") { + return {value}; + } + + return value; +} + +export const SummaryField: React.FC = ({ label, value }) => ( +
+ {label} +
{renderSummaryFieldValue(value)}
+
+); + +export const SummaryMetric: React.FC = ({ label, value }) => ( +
+ {label} + {value} +
+); + +export const SettingsPageShell: React.FC = ({ + children, + content, +}) => ( + history.push("/overview")} + > + {children} + +); diff --git a/apps/aevatar-console-web/src/pages/studio/components/StudioBootstrapGate.tsx b/apps/aevatar-console-web/src/pages/studio/components/StudioBootstrapGate.tsx index fe1d5ff65..3b9a58ad3 100644 --- a/apps/aevatar-console-web/src/pages/studio/components/StudioBootstrapGate.tsx +++ b/apps/aevatar-console-web/src/pages/studio/components/StudioBootstrapGate.tsx @@ -1,5 +1,6 @@ import { Tag, Typography } from 'antd'; import React from 'react'; +import { describeError } from '@/shared/ui/errorText'; import { embeddedPanelStyle } from '@/shared/ui/proComponents'; type StudioBootstrapGateProps = { @@ -34,6 +35,15 @@ const studioBootstrapNoticeCardStyle: React.CSSProperties = { padding: '12px 14px', }; +const studioBootstrapNoticeDescriptionStyle: React.CSSProperties = { + margin: 0, + display: '-webkit-box', + overflow: 'hidden', + wordBreak: 'break-word', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, +}; + function getStudioBootstrapNoticeAccent( type: StudioBootstrapNoticeProps['type'], ): { background: string; borderColor: string; label: string } { @@ -76,7 +86,11 @@ const StudioBootstrapNotice: React.FC = ({ > {accent.label} {title} - + {description} @@ -84,7 +98,7 @@ const StudioBootstrapNotice: React.FC = ({ }; function renderErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + return describeError(error); } const StudioBootstrapGate: React.FC = ({ diff --git a/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx b/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx index e9305769e..aa544ec4c 100644 --- a/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx +++ b/apps/aevatar-console-web/src/pages/studio/components/StudioWorkbenchSections.tsx @@ -104,6 +104,7 @@ import { summaryMetricStyle, summaryMetricValueStyle, } from '@/shared/ui/proComponents'; +import { describeError } from '@/shared/ui/errorText'; type QueryState = { readonly isLoading: boolean; @@ -951,7 +952,7 @@ const StudioScopeBindingPanel: React.FC = ({ ) : !binding?.available ? ( = ({ ) : (
@@ -1695,7 +1696,7 @@ export const StudioWorkflowsPage: React.FC = ({ ) : filteredWorkflows.length > 0 ? ( workflowLayout === 'grid' ? ( @@ -2587,7 +2588,7 @@ export const StudioExecutionPage: React.FC = ({ ) : selectedExecution.data ? ( renderExecutionLogsSection({ fullscreen: true }) @@ -2609,14 +2610,14 @@ export const StudioExecutionPage: React.FC = ({ ) : null} {selectedExecution.isError ? ( ) : null} {executionNotice ? ( @@ -3524,14 +3525,14 @@ export const StudioEditorPage: React.FC = ({ key="selected-workflow-error" type="error" title="Failed to load Studio workflow" - description={String(selectedWorkflow.error)} + description={describeError(selectedWorkflow.error)} /> ) : templateWorkflow.isError ? ( ) : null; @@ -4529,7 +4530,7 @@ export const StudioRolesPage: React.FC = ({
{roles.isError ? ( -
{String(roles.error)}
+
{describeError(roles.error)}
) : roles.isLoading ? (
Loading roles...
) : ( @@ -5064,7 +5065,7 @@ export const StudioConnectorsPage: React.FC = ({ {connectors.isError ? ( -
{String(connectors.error)}
+
{describeError(connectors.error)}
) : connectors.isLoading ? (
Loading connectors...
) : ( @@ -5677,7 +5678,7 @@ export const StudioSettingsPage: React.FC = ({ ) : settingsDraft ? (
@@ -6187,13 +6188,13 @@ export const StudioSettingsPage: React.FC = ({ ) : settings.isError ? ( ) : settingsDraft ? (
@@ -6254,7 +6255,7 @@ export const StudioSettingsPage: React.FC = ({ ) : workspaceSettings.data ? (
diff --git a/apps/aevatar-console-web/src/pages/studio/index.tsx b/apps/aevatar-console-web/src/pages/studio/index.tsx index b92bd5195..1655724a8 100644 --- a/apps/aevatar-console-web/src/pages/studio/index.tsx +++ b/apps/aevatar-console-web/src/pages/studio/index.tsx @@ -22,12 +22,6 @@ import React, { import { ensureActiveAuthSession } from '@/shared/auth/client'; import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { sanitizeReturnTo } from '@/shared/auth/session'; -import { - CONSOLE_PREFERENCES_UPDATED_EVENT, - loadConsolePreferences, - type StudioAppearanceTheme, - type StudioColorMode, -} from '@/shared/preferences/consolePreferences'; import { clearPlaygroundPromptHistory, loadPlaygroundPromptHistory, @@ -163,11 +157,19 @@ type StudioSettingsDraft = { readonly providers: StudioProviderSettings[]; }; +type StudioAppearanceTheme = 'blue' | 'coral' | 'forest'; +type StudioColorMode = 'light' | 'dark'; + type StudioAppearancePreferences = { readonly appearanceTheme: StudioAppearanceTheme; readonly colorMode: StudioColorMode; }; +const defaultStudioAppearance: StudioAppearancePreferences = { + appearanceTheme: 'blue', + colorMode: 'light', +}; + let studioLocalKeyCounter = 0; const STUDIO_AUTO_RELOGIN_ATTEMPT_KEY = 'aevatar-console:studio:auto-relogin:'; @@ -790,14 +792,6 @@ function createProviderDraft( }; } -function readStudioAppearancePreferences(): StudioAppearancePreferences { - const preferences = loadConsolePreferences(); - return { - appearanceTheme: preferences.studioAppearanceTheme, - colorMode: preferences.studioColorMode, - }; -} - function isExecutionStopAllowed(status: string | undefined): boolean { const normalized = status?.trim().toLowerCase() ?? ''; return !['completed', 'failed', 'stopped', 'cancelled'].includes(normalized); @@ -1001,8 +995,6 @@ const StudioPage: React.FC = () => { const [selectedProviderName, setSelectedProviderName] = useState(''); const [settingsPending, setSettingsPending] = useState(false); const [settingsNotice, setSettingsNotice] = useState(null); - const [studioAppearance, setStudioAppearance] = - useState(() => readStudioAppearancePreferences()); const [runtimeTestPending, setRuntimeTestPending] = useState(false); const [runtimeTestResult, setRuntimeTestResult] = useState(null); @@ -1034,6 +1026,7 @@ const StudioPage: React.FC = () => { Boolean(authSessionQuery.data?.authenticated); const studioHostReady = studioHostAccessResolved && studioHostAuthenticated; + const studioAppearance = defaultStudioAppearance; useEffect(() => { if (typeof window === 'undefined') { @@ -1281,30 +1274,6 @@ const StudioPage: React.FC = () => { }), }); - useEffect(() => { - if (typeof window === 'undefined') { - return undefined; - } - - const syncStudioAppearance = () => { - setStudioAppearance(readStudioAppearancePreferences()); - }; - - window.addEventListener( - CONSOLE_PREFERENCES_UPDATED_EVENT, - syncStudioAppearance, - ); - window.addEventListener('storage', syncStudioAppearance); - - return () => { - window.removeEventListener( - CONSOLE_PREFERENCES_UPDATED_EVENT, - syncStudioAppearance, - ); - window.removeEventListener('storage', syncStudioAppearance); - }; - }, []); - useEffect(() => { if ( selectedWorkflowId || diff --git a/apps/aevatar-console-web/src/pages/workflows/index.tsx b/apps/aevatar-console-web/src/pages/workflows/index.tsx index cd21dd6bf..4e25d5a40 100644 --- a/apps/aevatar-console-web/src/pages/workflows/index.tsx +++ b/apps/aevatar-console-web/src/pages/workflows/index.tsx @@ -65,6 +65,7 @@ import { tallScrollPanelStyle, stretchColumnStyle, } from "@/shared/ui/proComponents"; +import { describeError } from "@/shared/ui/errorText"; import WorkflowYamlViewer from "./WorkflowYamlViewer"; import { buildStepRows, @@ -1602,7 +1603,7 @@ const WorkflowsPage: React.FC = () => { showIcon type="error" title="Failed to load workflow catalog" - description={String(catalogQuery.error)} + description={describeError(catalogQuery.error)} /> ) : null} @@ -1658,7 +1659,7 @@ const WorkflowsPage: React.FC = () => { showIcon type="error" title="Failed to load workflow detail" - description={String(detailQuery.error)} + description={describeError(detailQuery.error)} /> ) : detailQuery.data ? (
diff --git a/apps/aevatar-console-web/src/shared/api/http/client.ts b/apps/aevatar-console-web/src/shared/api/http/client.ts index 8d2bf9053..f92a2f41b 100644 --- a/apps/aevatar-console-web/src/shared/api/http/client.ts +++ b/apps/aevatar-console-web/src/shared/api/http/client.ts @@ -1,5 +1,6 @@ import { authFetch } from "@/shared/auth/fetch"; import type { Decoder } from "../decodeUtils"; +import { readResponseError } from "./error"; export type QueryValue = | string @@ -14,24 +15,6 @@ const JSON_HEADERS = { "Content-Type": "application/json", }; -async function readError(response: Response): Promise { - const text = await response.text(); - if (!text) { - return `HTTP ${response.status}`; - } - - try { - const payload = JSON.parse(text) as { - message?: string; - error?: string; - code?: string; - }; - return payload.message || payload.error || payload.code || text; - } catch { - return text; - } -} - export function withQuery( path: string, query?: Record @@ -70,7 +53,7 @@ export async function requestJson( ): Promise { const response = await authFetch(input, init); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } return decoder(await response.json()); diff --git a/apps/aevatar-console-web/src/shared/api/http/error.ts b/apps/aevatar-console-web/src/shared/api/http/error.ts new file mode 100644 index 000000000..ff23b0bef --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/http/error.ts @@ -0,0 +1,73 @@ +function normalizeWhitespace(value: string | null | undefined): string { + return String(value ?? "") + .replace(/\s+/g, " ") + .trim(); +} + +function formatHttpError(status: number, statusText: string): string { + const normalizedStatusText = normalizeWhitespace(statusText); + return normalizedStatusText ? `HTTP ${status} ${normalizedStatusText}` : `HTTP ${status}`; +} + +function stripHtmlTags(value: string): string { + return value + .replace(/)<[^<]*)*<\/script>/gi, " ") + .replace(/)<[^<]*)*<\/style>/gi, " ") + .replace(/<[^>]+>/g, " "); +} + +function extractHtmlErrorSummary(value: string): string | null { + const trimmed = value.trimStart(); + const looksLikeHtml = + /^]/i.test(trimmed) || + /]/i.test(trimmed) || + /]/i.test(trimmed); + + if (!looksLikeHtml) { + return null; + } + + const titleMatch = value.match(/]*>([\s\S]*?)<\/title>/i); + const headingMatch = value.match(/]*>([\s\S]*?)<\/h1>/i); + const summary = normalizeWhitespace( + stripHtmlTags(titleMatch?.[1] ?? headingMatch?.[1] ?? value) + ); + + return summary || null; +} + +export async function readResponseError(response: Pick): Promise { + const text = await response.text(); + if (!text) { + return formatHttpError(response.status, response.statusText); + } + + try { + const payload = JSON.parse(text) as { + code?: string; + error?: string; + message?: string; + }; + return payload.message || payload.error || payload.code || text; + } catch { + const htmlSummary = extractHtmlErrorSummary(text); + if (!htmlSummary) { + return normalizeWhitespace(text); + } + + const httpError = formatHttpError(response.status, response.statusText); + const normalizedHttpError = httpError.toLowerCase(); + const normalizedHtmlSummary = htmlSummary.toLowerCase(); + const normalizedStatusText = normalizeWhitespace(response.statusText).toLowerCase(); + + if ( + normalizedHttpError.includes(normalizedHtmlSummary) || + normalizedHtmlSummary.includes(normalizedStatusText) + ) { + return httpError; + } + + return `${httpError}: ${htmlSummary}`; + } +} diff --git a/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts index 3f4b5e1ec..d3a5d77a0 100644 --- a/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.test.ts @@ -37,6 +37,63 @@ describe("runtimeRunsApi", () => { ).rejects.toThrow("invalid workflow yaml"); }); + it("collapses HTML error pages for streaming runtime responses", async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 502, + statusText: "Bad Gateway", + text: async () => ` + + + runtime gateway | 502: Bad gateway + + +

Bad gateway

+ +`, + } satisfies Partial); + + global.fetch = fetchMock as typeof global.fetch; + + await expect( + runtimeRunsApi.streamChat( + "scope-1", + { + prompt: "Run it", + }, + new AbortController().signal + ) + ).rejects.toThrow("HTTP 502 Bad Gateway"); + }); + + it("collapses HTML error pages for JSON runtime requests", async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 502, + statusText: "Bad Gateway", + text: async () => ` + + + runtime gateway | 502: Bad gateway + + +

Bad gateway

+ +`, + } satisfies Partial); + + global.fetch = fetchMock as typeof global.fetch; + + await expect( + runtimeRunsApi.resume("scope-1", { + actorId: "actor-1", + runId: "run-1", + stepId: "step-1", + approved: true, + }) + ).rejects.toThrow("HTTP 502 Bad Gateway"); + }); + it("decodes resume responses from the runtime boundary", async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: true, diff --git a/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts index f1add86a6..90927cb5e 100644 --- a/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts +++ b/apps/aevatar-console-web/src/shared/api/runtimeRunsApi.ts @@ -11,6 +11,7 @@ import { decodeWorkflowSignalResponseBody, } from "./runtimeDecoders"; import { requestJson } from "./http/client"; +import { readResponseError } from "./http/error"; import { encodeAppScriptCommandBase64, encodeStringValueBase64, @@ -36,24 +37,6 @@ function compactObject>(value: T): T { ) as T; } -async function readError(response: Response): Promise { - const text = await response.text(); - if (!text) { - return `HTTP ${response.status}`; - } - - try { - const payload = JSON.parse(text) as { - message?: string; - error?: string; - code?: string; - }; - return payload.message || payload.error || payload.code || text; - } catch { - return text; - } -} - function encodeSegment(value: string): string { return encodeURIComponent(value.trim()); } @@ -198,7 +181,7 @@ export const runtimeRunsApi = { ); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } return response; @@ -229,7 +212,7 @@ export const runtimeRunsApi = { }); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } return response; @@ -358,7 +341,7 @@ export const runtimeRunsApi = { ); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } return (await response.json()) as WorkflowStopResponse; diff --git a/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts b/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts index 1a6900bfe..f690d5882 100644 --- a/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts +++ b/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts @@ -3,7 +3,6 @@ const runtimePaths = { primitives: "/runtime/primitives", runs: "/runtime/runs", explorer: "/runtime/explorer", - observability: "/runtime/observability", } as const; type QueryValue = string | undefined; @@ -75,13 +74,3 @@ export function buildRuntimeExplorerHref(options?: { }): string { return buildHref(runtimePaths.explorer, options); } - -export function buildRuntimeObservabilityHref(options?: { - workflow?: string; - actorId?: string; - commandId?: string; - runId?: string; - stepId?: string; -}): string { - return buildHref(runtimePaths.observability, options); -} diff --git a/apps/aevatar-console-web/src/shared/observability/observabilityLinks.test.ts b/apps/aevatar-console-web/src/shared/observability/observabilityLinks.test.ts deleted file mode 100644 index 245f179e8..000000000 --- a/apps/aevatar-console-web/src/shared/observability/observabilityLinks.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ConsolePreferences } from '@/shared/preferences/consolePreferences'; -import { - buildObservabilityTargets, - normalizeBaseUrl, - type ObservabilityContext, -} from './observabilityLinks'; - -const preferences: ConsolePreferences = { - preferredWorkflow: 'direct', - actorTimelineTake: 50, - actorGraphDepth: 3, - actorGraphTake: 100, - actorGraphDirection: 'Both', - studioAppearanceTheme: 'blue', - studioColorMode: 'light', - grafanaBaseUrl: 'https://grafana.example.com/', - jaegerBaseUrl: ' https://jaeger.example.com ', - lokiBaseUrl: '', -}; - -const context: ObservabilityContext = { - workflow: 'direct', - actorId: 'actor-1', - commandId: 'cmd-1', - runId: '', - stepId: '', -}; - -describe('observabilityLinks', () => { - it('normalizes base urls and builds target links', () => { - expect(normalizeBaseUrl(' https://grafana.example.com/ ')).toBe( - 'https://grafana.example.com', - ); - - const targets = buildObservabilityTargets(preferences, context); - - expect(targets[0].exploreUrl).toBe('https://grafana.example.com/explore'); - expect(targets[1].exploreUrl).toBe('https://jaeger.example.com/search'); - expect(targets[2].status).toBe('missing'); - }); - - it('includes selected context summary in target metadata', () => { - const targets = buildObservabilityTargets(preferences, context); - - expect(targets[0].contextSummary).toContain('workflow=direct'); - expect(targets[0].contextSummary).toContain('actorId=actor-1'); - expect(targets[0].contextSummary).toContain('commandId=cmd-1'); - }); -}); diff --git a/apps/aevatar-console-web/src/shared/observability/observabilityLinks.ts b/apps/aevatar-console-web/src/shared/observability/observabilityLinks.ts deleted file mode 100644 index 6091add80..000000000 --- a/apps/aevatar-console-web/src/shared/observability/observabilityLinks.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ConsolePreferences } from '@/shared/preferences/consolePreferences'; - -export type ObservabilityContext = { - workflow: string; - actorId: string; - commandId: string; - runId: string; - stepId: string; -}; - -export type ObservabilityTarget = { - id: 'grafana' | 'jaeger' | 'loki'; - label: string; - status: 'configured' | 'missing'; - homeUrl: string; - exploreUrl: string; - description: string; - contextSummary: string; -}; - -export function normalizeBaseUrl(value: string): string { - return value.trim().replace(/\/+$/, ''); -} - -function buildContextSummary(context: ObservabilityContext): string { - const parts = [ - context.workflow ? `workflow=${context.workflow}` : '', - context.actorId ? `actorId=${context.actorId}` : '', - context.commandId ? `commandId=${context.commandId}` : '', - context.runId ? `runId=${context.runId}` : '', - context.stepId ? `stepId=${context.stepId}` : '', - ].filter(Boolean); - - return parts.length > 0 ? parts.join(' | ') : 'No context selected'; -} - -export function buildObservabilityTargets( - preferences: ConsolePreferences, - context: ObservabilityContext, -): ObservabilityTarget[] { - const grafanaBaseUrl = normalizeBaseUrl(preferences.grafanaBaseUrl); - const jaegerBaseUrl = normalizeBaseUrl(preferences.jaegerBaseUrl); - const lokiBaseUrl = normalizeBaseUrl(preferences.lokiBaseUrl); - const contextSummary = buildContextSummary(context); - - return [ - { - id: 'grafana', - label: 'Grafana', - status: grafanaBaseUrl ? 'configured' : 'missing', - homeUrl: grafanaBaseUrl, - exploreUrl: grafanaBaseUrl ? `${grafanaBaseUrl}/explore` : '', - description: 'Dashboards and Explore entrypoint for traces, logs, and linked views.', - contextSummary, - }, - { - id: 'jaeger', - label: 'Jaeger', - status: jaegerBaseUrl ? 'configured' : 'missing', - homeUrl: jaegerBaseUrl, - exploreUrl: jaegerBaseUrl ? `${jaegerBaseUrl}/search` : '', - description: 'Trace search and timeline inspection for distributed workflow execution.', - contextSummary, - }, - { - id: 'loki', - label: 'Loki', - status: lokiBaseUrl ? 'configured' : 'missing', - homeUrl: lokiBaseUrl, - exploreUrl: lokiBaseUrl, - description: 'Log aggregation entrypoint for actor, workflow, and command correlations.', - contextSummary, - }, - ]; -} diff --git a/apps/aevatar-console-web/src/shared/preferences/consolePreferences.test.ts b/apps/aevatar-console-web/src/shared/preferences/consolePreferences.test.ts deleted file mode 100644 index df748c558..000000000 --- a/apps/aevatar-console-web/src/shared/preferences/consolePreferences.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - defaultConsolePreferences, - loadConsolePreferences, - resetConsolePreferences, - saveConsolePreferences, -} from './consolePreferences'; - -describe('consolePreferences', () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it('loads defaults when storage is empty and persists sanitized values', () => { - expect(loadConsolePreferences()).toEqual(defaultConsolePreferences); - - const saved = saveConsolePreferences({ - preferredWorkflow: ' human_input_manual_triage ', - actorTimelineTake: 30.9, - actorGraphDepth: 2, - actorGraphTake: 80, - actorGraphDirection: 'Outbound', - studioAppearanceTheme: 'forest', - studioColorMode: 'dark', - grafanaBaseUrl: ' https://grafana.example.com ', - jaegerBaseUrl: ' https://jaeger.example.com ', - lokiBaseUrl: ' https://loki.example.com ', - }); - - expect(saved).toEqual({ - preferredWorkflow: 'human_input_manual_triage', - actorTimelineTake: 30, - actorGraphDepth: 2, - actorGraphTake: 80, - actorGraphDirection: 'Outbound', - studioAppearanceTheme: 'forest', - studioColorMode: 'dark', - grafanaBaseUrl: 'https://grafana.example.com', - jaegerBaseUrl: 'https://jaeger.example.com', - lokiBaseUrl: 'https://loki.example.com', - }); - - expect(loadConsolePreferences()).toEqual(saved); - expect(resetConsolePreferences()).toEqual(defaultConsolePreferences); - expect(loadConsolePreferences()).toEqual(defaultConsolePreferences); - }); -}); diff --git a/apps/aevatar-console-web/src/shared/preferences/consolePreferences.ts b/apps/aevatar-console-web/src/shared/preferences/consolePreferences.ts deleted file mode 100644 index 39842a7c7..000000000 --- a/apps/aevatar-console-web/src/shared/preferences/consolePreferences.ts +++ /dev/null @@ -1,133 +0,0 @@ -export type ActorGraphDirection = 'Both' | 'Outbound' | 'Inbound'; -export type StudioAppearanceTheme = 'blue' | 'coral' | 'forest'; -export type StudioColorMode = 'light' | 'dark'; - -export interface ConsolePreferences { - preferredWorkflow: string; - actorTimelineTake: number; - actorGraphDepth: number; - actorGraphTake: number; - actorGraphDirection: ActorGraphDirection; - studioAppearanceTheme: StudioAppearanceTheme; - studioColorMode: StudioColorMode; - grafanaBaseUrl: string; - jaegerBaseUrl: string; - lokiBaseUrl: string; -} - -export const CONSOLE_PREFERENCES_UPDATED_EVENT = - 'aevatar-console-preferences-updated'; - -const STORAGE_KEY = 'aevatar-console-preferences'; - -export const defaultConsolePreferences: ConsolePreferences = { - preferredWorkflow: 'direct', - actorTimelineTake: 50, - actorGraphDepth: 3, - actorGraphTake: 100, - actorGraphDirection: 'Both', - studioAppearanceTheme: 'blue', - studioColorMode: 'light', - grafanaBaseUrl: '', - jaegerBaseUrl: '', - lokiBaseUrl: '', -}; - -function parsePositiveInt(value: unknown, fallback: number): number { - if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) { - return fallback; - } - - return Math.floor(value); -} - -function parseDirection(value: unknown): ActorGraphDirection { - if (value === 'Inbound' || value === 'Outbound' || value === 'Both') { - return value; - } - - return defaultConsolePreferences.actorGraphDirection; -} - -function parseStudioAppearanceTheme(value: unknown): StudioAppearanceTheme { - if (value === 'coral' || value === 'forest' || value === 'blue') { - return value; - } - - return defaultConsolePreferences.studioAppearanceTheme; -} - -function parseStudioColorMode(value: unknown): StudioColorMode { - return value === 'dark' ? 'dark' : defaultConsolePreferences.studioColorMode; -} - -function sanitizePreferences( - value: Partial | null | undefined, -): ConsolePreferences { - return { - preferredWorkflow: - typeof value?.preferredWorkflow === 'string' && - value.preferredWorkflow.trim().length > 0 - ? value.preferredWorkflow.trim() - : defaultConsolePreferences.preferredWorkflow, - actorTimelineTake: parsePositiveInt( - value?.actorTimelineTake, - defaultConsolePreferences.actorTimelineTake, - ), - actorGraphDepth: parsePositiveInt( - value?.actorGraphDepth, - defaultConsolePreferences.actorGraphDepth, - ), - actorGraphTake: parsePositiveInt( - value?.actorGraphTake, - defaultConsolePreferences.actorGraphTake, - ), - actorGraphDirection: parseDirection(value?.actorGraphDirection), - studioAppearanceTheme: parseStudioAppearanceTheme( - value?.studioAppearanceTheme, - ), - studioColorMode: parseStudioColorMode(value?.studioColorMode), - grafanaBaseUrl: - typeof value?.grafanaBaseUrl === 'string' ? value.grafanaBaseUrl.trim() : '', - jaegerBaseUrl: - typeof value?.jaegerBaseUrl === 'string' ? value.jaegerBaseUrl.trim() : '', - lokiBaseUrl: - typeof value?.lokiBaseUrl === 'string' ? value.lokiBaseUrl.trim() : '', - }; -} - -export function loadConsolePreferences(): ConsolePreferences { - if (typeof window === 'undefined') { - return defaultConsolePreferences; - } - - const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) { - return defaultConsolePreferences; - } - - try { - return sanitizePreferences(JSON.parse(raw) as Partial); - } catch { - return defaultConsolePreferences; - } -} - -export function saveConsolePreferences(value: ConsolePreferences): ConsolePreferences { - const sanitized = sanitizePreferences(value); - if (typeof window !== 'undefined') { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitized)); - window.dispatchEvent(new Event(CONSOLE_PREFERENCES_UPDATED_EVENT)); - } - - return sanitized; -} - -export function resetConsolePreferences(): ConsolePreferences { - if (typeof window !== 'undefined') { - window.localStorage.removeItem(STORAGE_KEY); - window.dispatchEvent(new Event(CONSOLE_PREFERENCES_UPDATED_EVENT)); - } - - return defaultConsolePreferences; -} diff --git a/apps/aevatar-console-web/src/shared/studio/api.test.ts b/apps/aevatar-console-web/src/shared/studio/api.test.ts index 93fbf5476..eddda0868 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.test.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.test.ts @@ -108,6 +108,28 @@ describe('studioApi host-session requests', () => { ); }); + it('collapses HTML error pages into a compact HTTP error message', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + text: async () => ` + + + aevatar.ai | 502: Bad gateway + + +

Bad gateway

+ +`, + } as Response); + global.fetch = fetchMock as typeof global.fetch; + + await expect(studioApi.getAuthSession()).rejects.toThrow( + 'HTTP 502 Bad Gateway', + ); + }); + it('sends available step types when parsing workflow yaml', async () => { persistAuthSession({ tokens: { diff --git a/apps/aevatar-console-web/src/shared/studio/api.ts b/apps/aevatar-console-web/src/shared/studio/api.ts index 9dcc637ec..16320dd78 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.ts @@ -44,6 +44,7 @@ import { readNumber, readString, } from "@/shared/api/http/decoders"; +import { readResponseError } from "@/shared/api/http/error"; import { decodeWorkflowCatalogItemDetailResponse } from "@/shared/api/runtimeDecoders"; import { authFetch } from "@/shared/auth/fetch"; @@ -80,28 +81,10 @@ function compactObject>(value: T): T { ) as T; } -async function readError(response: Response): Promise { - const text = await response.text(); - if (!text) { - return `HTTP ${response.status}`; - } - - try { - const payload = JSON.parse(text) as { - message?: string; - error?: string; - code?: string; - }; - return payload.message || payload.error || payload.code || text; - } catch { - return text; - } -} - async function requestJson(input: string, init?: RequestInit): Promise { const response = await studioHostFetch(input, init); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } if (response.status === 204) { @@ -118,7 +101,7 @@ async function requestDecodedJson( ): Promise { const response = await studioHostFetch(input, init); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } if (response.status === 204) { @@ -144,7 +127,7 @@ async function request(input: string, init?: RequestInit): Promise { headers, }); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } if (response.status === 204) { @@ -174,7 +157,7 @@ async function streamSse( signal, }); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } if (!response.body) { diff --git a/apps/aevatar-console-web/src/shared/studio/scriptsApi.test.ts b/apps/aevatar-console-web/src/shared/studio/scriptsApi.test.ts index c8173dd41..421ce195a 100644 --- a/apps/aevatar-console-web/src/shared/studio/scriptsApi.test.ts +++ b/apps/aevatar-console-web/src/shared/studio/scriptsApi.test.ts @@ -123,4 +123,30 @@ describe('scriptsApi host-session requests', () => { runtimeActorId: 'runtime-1', }); }); + + it('collapses HTML error pages for Studio script endpoints', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + text: async () => ` + + + scripts gateway | 502: Bad gateway + + +

Bad gateway

+ +`, + } as Response); + global.fetch = fetchMock as typeof global.fetch; + + await expect( + scriptsApi.validateDraft({ + scriptId: 'demo', + scriptRevision: 'draft-1', + source: 'public class Demo {}', + }), + ).rejects.toThrow('HTTP 502 Bad Gateway'); + }); }); diff --git a/apps/aevatar-console-web/src/shared/studio/scriptsApi.ts b/apps/aevatar-console-web/src/shared/studio/scriptsApi.ts index 2853769d9..70fae7f56 100644 --- a/apps/aevatar-console-web/src/shared/studio/scriptsApi.ts +++ b/apps/aevatar-console-web/src/shared/studio/scriptsApi.ts @@ -1,4 +1,5 @@ import { authFetch } from '@/shared/auth/fetch'; +import { readResponseError } from '@/shared/api/http/error'; import type { DraftRunResult, GeneratedScriptResult, @@ -44,31 +45,13 @@ function isJsonContentType(contentType: string | null): boolean { return value.includes('application/json') || value.includes('+json'); } -async function readError(response: Response): Promise { - const text = await response.text(); - if (!text) { - return `HTTP ${response.status}`; - } - - try { - const payload = JSON.parse(text) as { - code?: string; - error?: string; - message?: string; - }; - return payload.message || payload.error || payload.code || text; - } catch { - return text; - } -} - async function requestJson( input: string, init?: RequestInit, ): Promise { const response = await scriptsFetch(input, init); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } if (response.status === 204) { @@ -159,7 +142,7 @@ async function streamSse( }); if (!response.ok) { - throw new Error(await readError(response)); + throw new Error(await readResponseError(response)); } if (!response.body) { diff --git a/apps/aevatar-console-web/src/shared/ui/errorText.ts b/apps/aevatar-console-web/src/shared/ui/errorText.ts new file mode 100644 index 000000000..523d808f2 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/ui/errorText.ts @@ -0,0 +1,26 @@ +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function describeError( + error: unknown, + fallback = "Unexpected error." +): string { + if (error instanceof Error) { + const message = normalizeWhitespace(error.message || error.name || ""); + return message || fallback; + } + + if (error && typeof error === "object" && !Array.isArray(error)) { + const record = error as { message?: unknown }; + if (typeof record.message === "string") { + const message = normalizeWhitespace(record.message); + if (message) { + return message; + } + } + } + + const text = normalizeWhitespace(String(error ?? "")); + return text || fallback; +}