This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 720d20bdc4ecb47d5e11eadd9a3678735431d808 Author: Logic <[email protected]> AuthorDate: Wed Aug 26 09:54:46 2026 +0800 feat: preserve guided instrumentation workflow --- web-app/src/assets/i18n/en-us.json | 38 +++++ web-app/src/assets/i18n/ja-jp.json | 38 +++++ web-app/src/assets/i18n/pt-br.json | 38 +++++ web-app/src/assets/i18n/zh-cn.json | 38 +++++ web-app/src/assets/i18n/zh-tw.json | 38 +++++ .../instrumentation-configure-step-props.ts | 42 +++++ .../components/instrumentation-configure-step.tsx | 162 ++++++-------------- .../instrumentation-configure.module.css | 138 ++--------------- ...instrumentation-destination-selector.module.css | 170 +++++++++++++++++++++ .../instrumentation-destination-selector.tsx | 146 +++++++++++++----- .../instrumentation-guide-configuration.module.css | 68 +++++++++ .../instrumentation-guide-configuration.tsx | 148 ++++++++++++++++++ .../components/instrumentation-guide-workspace.tsx | 37 ++++- .../instrumentation-guided-configuration.test.tsx | 160 +++++++++++++++++++ .../instrumentation-onboarding.module.css | 33 +++- .../components/instrumentation-progress.tsx | 21 ++- .../components/instrumentation-shell.module.css | 55 +++++-- .../components/instrumentation-v2-flow.test.tsx | 91 +++++++---- .../instrumentation/instrumentation-i18n.test.ts | 9 ++ .../instrumentation/model/instrumentation-flow.ts | 2 +- .../model/instrumentation-guided-flow.ts | 24 +++ .../pages/instrumentation-page.test.tsx | 5 +- .../instrumentation/pages/instrumentation-page.tsx | 140 +++++------------ .../pages/instrumentation-stage-content.tsx | 130 ++++++++++++++++ 24 files changed, 1320 insertions(+), 451 deletions(-) diff --git a/web-app/src/assets/i18n/en-us.json b/web-app/src/assets/i18n/en-us.json index 17986ec1cb..6b8932318f 100644 --- a/web-app/src/assets/i18n/en-us.json +++ b/web-app/src/assets/i18n/en-us.json @@ -645,6 +645,27 @@ "contextTitle": "Service and destination", "configureTitle": "Configure", "configureDescription": "Name the service, choose where telemetry is sent, then generate the exact instructions.", + "guided": { + "stage": { + "source": "Source", + "service": "Service details", + "destination": "Intake path", + "guide": "Setup guide" + }, + "serviceTitle": "Which service owns this telemetry?", + "serviceDescription": "These values become telemetry resource attributes used for querying, filtering, and correlation.", + "destinationTitle": "Where should telemetry be sent first?", + "destinationDescription": "Choose one intake path. Connection details are shown only for the current selection.", + "guideTitle": "Generate setup instructions", + "guideDescription": "Confirm the destination and authorization, then generate steps for this service.", + "currentIntake": "Current intake", + "selectAvailableDestination": "Choose an available intake path to review its connection details.", + "selectedDestination": "Intake path", + "online": "Online", + "collectorId": "Collector ID", + "destinationInstance": "Intake instance", + "copyEndpoint": "Copy {{transport}} endpoint" + }, "serviceContext": "Service context", "installTitle": "Install and configure", "recipeLabel": "Instrumentation recipe", @@ -885,6 +906,15 @@ "tokenMissing": "Not provided", "token": { "generateAccess": "Generate access token", + "create": "Create access token", + "useExisting": "Use an existing token", + "cancelExisting": "Cancel", + "setupTitle": "Create a token to continue", + "setupDescription": "It will be added to this setup automatically. Continue once it is ready.", + "existingTitle": "Use an existing token", + "existingDescription": "Paste a token only if one has already been issued for OTLP ingestion.", + "readyTitle": "Access token ready", + "readyDescription": "It will be inserted into the setup guide automatically.", "generateTitle": "Generate access token", "generate": "Generate", "name": "Token name", @@ -895,6 +925,7 @@ "notGenerated": "No access token generated", "generateError": "The access token could not be generated.", "acknowledgementRequired": "Save this one-time token before leaving or starting over. It exists only in this page's memory.", + "guideAcknowledgementRequired": "This one-time token is visible in the guide below. Save it before leaving or starting over.", "acknowledge": "I saved this token", "plaintextBearerWarning": "The Bearer token will be transmitted over an unencrypted connection." }, @@ -940,6 +971,8 @@ }, "action": { "exit": "Back to dashboard", + "previous": "Previous", + "next": "Next", "continue": "Continue", "render": "Render instructions", "reviewContext": "Review context", @@ -1286,6 +1319,8 @@ "selectedPauseConfirm_other": "Pause {{count}} selected monitors?", "delete": "Delete", "deleteConfirm": "Delete the selected monitor data?", + "selectAll": "Select all monitors", + "selectOne": "Select {{name}}", "selected": "{{count}} selected", "clearSelection": "Clear selection", "success": "Monitor operation completed.", @@ -1362,6 +1397,9 @@ "average": "Avg", "samples": "Samples", "refreshHistory": "Refresh history", + "investigation": { + "open": "Related signals" + }, "collapseHistory": "Collapse history", "expandHistory": "Expand history", "layout": { diff --git a/web-app/src/assets/i18n/ja-jp.json b/web-app/src/assets/i18n/ja-jp.json index 462e1e2c3a..3943ee92c0 100644 --- a/web-app/src/assets/i18n/ja-jp.json +++ b/web-app/src/assets/i18n/ja-jp.json @@ -642,6 +642,27 @@ "contextTitle": "サービスと送信先", "configureTitle": "設定", "configureDescription": "サービス名と送信先を設定し、正確な接続手順を生成します。", + "guided": { + "stage": { + "source": "接続元", + "service": "サービス情報", + "destination": "取り込み経路", + "guide": "接続ガイド" + }, + "serviceTitle": "このテレメトリはどのサービスのものですか?", + "serviceDescription": "これらの値は、検索、絞り込み、関連付けに使用するリソース属性になります。", + "destinationTitle": "テレメトリを最初にどこへ送信しますか?", + "destinationDescription": "取り込み経路を選択してください。接続情報は現在の選択にのみ表示されます。", + "guideTitle": "接続手順を生成", + "guideDescription": "送信先と認証方式を確認し、このサービス向けの手順を生成します。", + "currentIntake": "現在の取り込み先", + "selectAvailableDestination": "利用可能な取り込み経路を選択すると、接続情報が表示されます。", + "selectedDestination": "取り込み経路", + "online": "オンライン", + "collectorId": "Collector ID", + "destinationInstance": "取り込みインスタンス", + "copyEndpoint": "{{transport}} エンドポイントをコピー" + }, "serviceContext": "サービスコンテキスト", "installTitle": "インストールと設定", "recipeLabel": "計装レシピ", @@ -882,6 +903,15 @@ "tokenMissing": "未入力", "token": { "generateAccess": "アクセストークンを生成", + "create": "アクセストークンを作成", + "useExisting": "既存のトークンを使用", + "cancelExisting": "キャンセル", + "setupTitle": "トークンを作成して続行", + "setupDescription": "トークンはこのセットアップに自動的に追加されます。作成後、次へ進めます。", + "existingTitle": "既存のトークンを使用", + "existingDescription": "OTLP 取り込み用トークンがすでに発行されている場合のみ貼り付けてください。", + "readyTitle": "アクセストークンの準備ができました", + "readyDescription": "次の手順でセットアップガイドに自動的に挿入されます。", "generateTitle": "アクセストークンを生成", "generate": "生成", "name": "トークン名", @@ -892,6 +922,7 @@ "notGenerated": "アクセストークンは未生成です", "generateError": "アクセストークンを生成できませんでした。", "acknowledgementRequired": "終了または最初からやり直す前に、この一度だけ表示されるトークンを保存してください。このページのメモリにのみ保持されています。", + "guideAcknowledgementRequired": "一度だけ表示されるトークンが下の接続ガイドにあります。終了または最初からやり直す前に保存してください。", "acknowledge": "トークンを保存しました", "plaintextBearerWarning": "Bearer トークンは暗号化されていない接続で送信されます。" }, @@ -937,6 +968,8 @@ }, "action": { "exit": "ダッシュボードに戻る", + "previous": "前へ", + "next": "次へ", "continue": "続行", "render": "手順を生成", "reviewContext": "コンテキストを確認", @@ -1283,6 +1316,8 @@ "selectedPauseConfirm_other": "選択した {{count}} 件の監視を一時停止しますか?", "delete": "削除", "deleteConfirm": "選択した監視データを削除しますか?", + "selectAll": "すべての監視を選択", + "selectOne": "{{name}} を選択", "selected": "{{count}} 件を選択", "clearSelection": "選択を解除", "success": "監視操作が完了しました。", @@ -1359,6 +1394,9 @@ "average": "平均", "samples": "サンプル", "refreshHistory": "履歴を更新", + "investigation": { + "open": "関連シグナル" + }, "collapseHistory": "履歴を折りたたむ", "expandHistory": "履歴を展開", "layout": { diff --git a/web-app/src/assets/i18n/pt-br.json b/web-app/src/assets/i18n/pt-br.json index aeb2040972..4378ed0061 100644 --- a/web-app/src/assets/i18n/pt-br.json +++ b/web-app/src/assets/i18n/pt-br.json @@ -648,6 +648,27 @@ "contextTitle": "Serviço e destino", "configureTitle": "Configurar", "configureDescription": "Nomeie o serviço, escolha o destino da telemetria e gere as instruções exatas.", + "guided": { + "stage": { + "source": "Origem", + "service": "Serviço", + "destination": "Rota de entrada", + "guide": "Guia de configuração" + }, + "serviceTitle": "A qual serviço esta telemetria pertence?", + "serviceDescription": "Estes valores se tornam atributos de recurso usados em consultas, filtros e correlações.", + "destinationTitle": "Para onde a telemetria deve ser enviada primeiro?", + "destinationDescription": "Escolha uma rota de entrada. Os detalhes de conexão aparecem apenas para a seleção atual.", + "guideTitle": "Gerar instruções de configuração", + "guideDescription": "Confirme o destino e a autorização para gerar as etapas deste serviço.", + "currentIntake": "Entrada atual", + "selectAvailableDestination": "Escolha uma rota disponível para ver os detalhes de conexão.", + "selectedDestination": "Rota de entrada", + "online": "Online", + "collectorId": "ID do Collector", + "destinationInstance": "Instância de entrada", + "copyEndpoint": "Copiar endpoint {{transport}}" + }, "serviceContext": "Contexto do serviço", "installTitle": "Instalar e configurar", "recipeLabel": "Receita de instrumentação", @@ -890,6 +911,15 @@ "tokenMissing": "Não informado", "token": { "generateAccess": "Gerar token de acesso", + "create": "Criar token de acesso", + "useExisting": "Usar um token existente", + "cancelExisting": "Cancelar", + "setupTitle": "Crie um token para continuar", + "setupDescription": "Ele será adicionado automaticamente a esta configuração. Continue quando estiver pronto.", + "existingTitle": "Usar um token existente", + "existingDescription": "Cole um token somente se ele já tiver sido emitido para a ingestão OTLP.", + "readyTitle": "Token de acesso pronto", + "readyDescription": "Ele será inserido automaticamente no guia de configuração.", "generateTitle": "Gerar token de acesso", "generate": "Gerar", "name": "Nome do token", @@ -900,6 +930,7 @@ "notGenerated": "Nenhum token de acesso gerado", "generateError": "Não foi possível gerar o token de acesso.", "acknowledgementRequired": "Salve este token de exibição única antes de sair ou recomeçar. Ele existe apenas na memória desta página.", + "guideAcknowledgementRequired": "Este token de exibição única está visível no guia abaixo. Salve-o antes de sair ou recomeçar.", "acknowledge": "Salvei este token", "plaintextBearerWarning": "O token Bearer será transmitido por uma conexão não criptografada." }, @@ -945,6 +976,8 @@ }, "action": { "exit": "Voltar ao painel", + "previous": "Anterior", + "next": "Próximo", "continue": "Continuar", "render": "Gerar instruções", "reviewContext": "Revisar contexto", @@ -1296,6 +1329,8 @@ "selectedPauseConfirm_other": "Pausar {{count}} monitores selecionados?", "delete": "Excluir", "deleteConfirm": "Excluir os dados dos monitores selecionados?", + "selectAll": "Selecionar todos os monitores", + "selectOne": "Selecionar {{name}}", "selected": "{{count}} selecionados", "clearSelection": "Limpar seleção", "success": "Operação de monitor concluída.", @@ -1372,6 +1407,9 @@ "average": "Média", "samples": "Amostras", "refreshHistory": "Atualizar histórico", + "investigation": { + "open": "Sinais relacionados" + }, "collapseHistory": "Recolher histórico", "expandHistory": "Expandir histórico", "layout": { diff --git a/web-app/src/assets/i18n/zh-cn.json b/web-app/src/assets/i18n/zh-cn.json index 96336ac501..0e45e59040 100644 --- a/web-app/src/assets/i18n/zh-cn.json +++ b/web-app/src/assets/i18n/zh-cn.json @@ -639,6 +639,27 @@ "contextTitle": "服务与目标位置", "configureTitle": "配置", "configureDescription": "命名服务、选择遥测数据的目标位置,然后生成准确的接入步骤。", + "guided": { + "stage": { + "source": "接入来源", + "service": "服务信息", + "destination": "接入路径", + "guide": "接入指南" + }, + "serviceTitle": "这批遥测数据属于哪个服务?", + "serviceDescription": "这些字段会写入遥测资源属性,用于查询、筛选和关联。", + "destinationTitle": "遥测数据先发到哪里?", + "destinationDescription": "先选一条接入路径。地址和连接状态只在当前选择中展开。", + "guideTitle": "生成接入说明", + "guideDescription": "确认目标位置和授权方式,然后生成适用于当前服务的配置步骤。", + "currentIntake": "当前接入", + "selectAvailableDestination": "选择一个可用的接入路径后,这里会显示连接地址。", + "selectedDestination": "接入路径", + "online": "在线", + "collectorId": "Collector ID", + "destinationInstance": "接入实例", + "copyEndpoint": "复制 {{transport}} 地址" + }, "serviceContext": "服务上下文", "installTitle": "安装并配置", "recipeLabel": "插桩方案", @@ -879,6 +900,15 @@ "tokenMissing": "未提供", "token": { "generateAccess": "生成访问令牌", + "create": "创建访问令牌", + "useExisting": "使用已有令牌", + "cancelExisting": "取消", + "setupTitle": "创建令牌后继续", + "setupDescription": "令牌会自动加入本次接入配置,创建完成后即可进入下一步。", + "existingTitle": "使用已有令牌", + "existingDescription": "仅在已有 OTLP 接入令牌时粘贴。", + "readyTitle": "访问令牌已准备好", + "readyDescription": "下一步会自动将它写入接入指南。", "generateTitle": "生成访问令牌", "generate": "生成", "name": "令牌名称", @@ -889,6 +919,7 @@ "notGenerated": "尚未生成访问令牌", "generateError": "无法生成访问令牌。", "acknowledgementRequired": "请在退出或重新开始前保存此一次性令牌。它仅存在于当前页面的内存中。", + "guideAcknowledgementRequired": "一次性令牌已写入下方接入指南,请在退出或重新开始前保存。", "acknowledge": "我已保存此令牌", "plaintextBearerWarning": "Bearer 令牌将通过未加密连接传输。" }, @@ -934,6 +965,8 @@ }, "action": { "exit": "返回仪表盘", + "previous": "上一步", + "next": "下一步", "continue": "继续", "render": "生成操作说明", "reviewContext": "检查上下文", @@ -1280,6 +1313,8 @@ "selectedPauseConfirm_other": "确认暂停所选 {{count}} 项监控?", "delete": "删除", "deleteConfirm": "确认删除所选监控数据?", + "selectAll": "选择全部监控", + "selectOne": "选择 {{name}}", "selected": "已选择 {{count}} 项", "clearSelection": "清除选择", "success": "监控操作已完成。", @@ -1356,6 +1391,9 @@ "average": "平均值", "samples": "样本数", "refreshHistory": "刷新历史趋势", + "investigation": { + "open": "关联信号" + }, "collapseHistory": "收起历史趋势", "expandHistory": "展开历史趋势", "layout": { diff --git a/web-app/src/assets/i18n/zh-tw.json b/web-app/src/assets/i18n/zh-tw.json index a7c316c92c..f3203cc4f3 100644 --- a/web-app/src/assets/i18n/zh-tw.json +++ b/web-app/src/assets/i18n/zh-tw.json @@ -639,6 +639,27 @@ "contextTitle": "服務與目的地", "configureTitle": "設定", "configureDescription": "命名服務、選擇遙測資料的目的地,然後產生準確的接入步驟。", + "guided": { + "stage": { + "source": "接入來源", + "service": "服務資訊", + "destination": "接入路徑", + "guide": "接入指南" + }, + "serviceTitle": "這批遙測資料屬於哪個服務?", + "serviceDescription": "這些欄位會寫入遙測資源屬性,用於查詢、篩選和關聯。", + "destinationTitle": "遙測資料先傳到哪裡?", + "destinationDescription": "先選一條接入路徑。位址和連線狀態只在目前選擇中展開。", + "guideTitle": "產生接入說明", + "guideDescription": "確認目的地和授權方式,然後產生適用於目前服務的設定步驟。", + "currentIntake": "目前接入", + "selectAvailableDestination": "選擇一個可用的接入路徑後,這裡會顯示連線位址。", + "selectedDestination": "接入路徑", + "online": "在線", + "collectorId": "Collector ID", + "destinationInstance": "接入實例", + "copyEndpoint": "複製 {{transport}} 位址" + }, "serviceContext": "服務上下文", "installTitle": "安裝並設定", "recipeLabel": "插樁方案", @@ -879,6 +900,15 @@ "tokenMissing": "未提供", "token": { "generateAccess": "產生存取權杖", + "create": "建立存取權杖", + "useExisting": "使用現有權杖", + "cancelExisting": "取消", + "setupTitle": "建立權杖後繼續", + "setupDescription": "權杖會自動加入本次接入設定,建立完成後即可進入下一步。", + "existingTitle": "使用現有權杖", + "existingDescription": "僅在已有 OTLP 接入權杖時貼上。", + "readyTitle": "存取權杖已準備好", + "readyDescription": "下一步會自動將它寫入接入指南。", "generateTitle": "產生存取權杖", "generate": "產生", "name": "權杖名稱", @@ -889,6 +919,7 @@ "notGenerated": "尚未產生存取權杖", "generateError": "無法產生存取權杖。", "acknowledgementRequired": "請在離開或重新開始前儲存此一次性權杖。它只存在於目前頁面的記憶體中。", + "guideAcknowledgementRequired": "一次性權杖已寫入下方接入指南,請在離開或重新開始前儲存。", "acknowledge": "我已儲存此權杖", "plaintextBearerWarning": "Bearer 權杖將透過未加密連線傳輸。" }, @@ -934,6 +965,8 @@ }, "action": { "exit": "返回儀表板", + "previous": "上一步", + "next": "下一步", "continue": "繼續", "render": "產生操作說明", "reviewContext": "檢查情境", @@ -1280,6 +1313,8 @@ "selectedPauseConfirm_other": "確認暫停所選 {{count}} 項監控?", "delete": "刪除", "deleteConfirm": "確認刪除所選監控資料?", + "selectAll": "選擇全部監控", + "selectOne": "選擇 {{name}}", "selected": "已選擇 {{count}} 項", "clearSelection": "清除選取", "success": "監控操作已完成。", @@ -1356,6 +1391,9 @@ "average": "平均值", "samples": "樣本數", "refreshHistory": "重新整理歷史趨勢", + "investigation": { + "open": "關聯訊號" + }, "collapseHistory": "收合歷史趨勢", "expandHistory": "展開歷史趨勢", "layout": { diff --git a/web-app/src/features/instrumentation/components/instrumentation-configure-step-props.ts b/web-app/src/features/instrumentation/components/instrumentation-configure-step-props.ts new file mode 100644 index 0000000000..08f7e0fdc2 --- /dev/null +++ b/web-app/src/features/instrumentation/components/instrumentation-configure-step-props.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import type { AccessTokenGenerationDraft } from '@/shared/access-token/access-token-generation-model'; + +import type { InstrumentationConfigurePhase } from '../model/instrumentation-guided-flow'; +import type { IntakeProfilesResponse, ServiceIdentity } from '../model/instrumentation-v2-contract'; + +export type ConfigureStepProps = { + phase: InstrumentationConfigurePhase; + profiles: IntakeProfilesResponse; + profileId: string; + service: ServiceIdentity; + platform?: string | undefined; + platformOptions: string[]; + canRender: boolean; + rendering: boolean; + renderError: boolean; + token: string; + tokenDraft?: AccessTokenGenerationDraft | undefined; + tokenGenerating: boolean; + tokenError: boolean; + tokenAcknowledgementRequired: boolean; + requiresToken: boolean; + canGenerateToken: boolean; + onProfile: (intakeProfileId: string) => void; + onService: (patch: Partial<ServiceIdentity>) => void; + onPlatform: (platform: string) => void; + onToken: (token: string) => void; + onRender: () => void; + onPrevious: () => void; + onNext: () => void; + onOpenToken: () => void; + onCloseToken: () => void; + onTokenDraft: (draft: AccessTokenGenerationDraft) => void; + onGenerateToken: () => void; + onAcknowledgeToken: () => void; +}; diff --git a/web-app/src/features/instrumentation/components/instrumentation-configure-step.tsx b/web-app/src/features/instrumentation/components/instrumentation-configure-step.tsx index a25a128124..cc56840e5b 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-configure-step.tsx +++ b/web-app/src/features/instrumentation/components/instrumentation-configure-step.tsx @@ -8,157 +8,93 @@ import { Alert, Button, Typography } from 'antd'; import { useTranslation } from 'react-i18next'; -import type { AccessTokenGenerationDraft } from '@/shared/access-token/access-token-generation-model'; - -import { profileUsesPlaintext } from '../model/intake-profile'; -import type { IntakeProfilesResponse, ServiceIdentity } from '../model/instrumentation-v2-contract'; -import { InstrumentationAccessTokenModal } from './instrumentation-access-token-modal'; -import { InstrumentationPlatformField, InstrumentationTokenField } from './instrumentation-configuration-fields'; +import { serviceConfigurationReady } from '../model/instrumentation-guided-flow'; +import { InstrumentationPlatformField } from './instrumentation-configuration-fields'; import styles from './instrumentation-configure.module.css'; +import type { ConfigureStepProps } from './instrumentation-configure-step-props'; import { InstrumentationDestinationSelector } from './instrumentation-destination-selector'; +import { InstrumentationGuideConfiguration, InstrumentationTokenModal } from './instrumentation-guide-configuration'; import { InstrumentationServiceIdentityFields } from './instrumentation-service-identity-fields'; -type ConfigureStepProps = { - profiles: IntakeProfilesResponse; - profileId: string; - service: ServiceIdentity; - platform?: string | undefined; - platformOptions: string[]; - canRender: boolean; - rendering: boolean; - renderError: boolean; - token: string; - tokenDraft?: AccessTokenGenerationDraft | undefined; - tokenGenerating: boolean; - tokenError: boolean; - tokenAcknowledgementRequired: boolean; - requiresToken: boolean; - canGenerateToken: boolean; - onProfile: (intakeProfileId: string) => void; - onService: (patch: Partial<ServiceIdentity>) => void; - onPlatform: (platform: string) => void; - onToken: (token: string) => void; - onRender: () => void; - onOpenToken: () => void; - onCloseToken: () => void; - onTokenDraft: (draft: AccessTokenGenerationDraft) => void; - onGenerateToken: () => void; - onAcknowledgeToken: () => void; -}; - export function InstrumentationConfigureStep(props: ConfigureStepProps) { const { t } = useTranslation(); + const headingKey = `instrumentation.v2.guided.${props.phase}Title`; + const descriptionKey = `instrumentation.v2.guided.${props.phase}Description`; return ( <section className={styles.section} aria-labelledby="instrumentation-configure-title"> <div className={styles.configureIntro}> <Typography.Title id="instrumentation-configure-title" level={3}> - {t('instrumentation.v2.configureTitle')} + {t(headingKey)} </Typography.Title> - <Typography.Text type="secondary">{t('instrumentation.v2.configureDescription')}</Typography.Text> + <Typography.Text type="secondary">{t(descriptionKey)}</Typography.Text> </div> - {props.profiles.status !== 'available' && ( + {props.phase === 'destination' && props.profiles.status !== 'available' && ( <Alert type={props.profiles.status === 'unavailable' ? 'error' : 'warning'} showIcon message={t(`instrumentation.v2.profile.${props.profiles.status}`)} /> )} - <div className={styles.configureWorkspace}> - <InstrumentationDestinationSelector - profiles={props.profiles} - profileId={props.profileId} - onProfile={props.onProfile} - /> - <div className={styles.configurePrimary}> - <section className={styles.configureGroup} aria-labelledby="instrumentation-service-context-title"> - <Typography.Title id="instrumentation-service-context-title" level={5}> - {t('instrumentation.v2.serviceContext')} - </Typography.Title> - <InstrumentationServiceIdentityFields service={props.service} onService={props.onService} /> - <InstrumentationPlatformField - platform={props.platform} - options={props.platformOptions} - onPlatform={props.onPlatform} - /> - </section> - <TokenConfiguration {...props} /> - </div> - </div> - <div className={styles.configureActions}> - <TokenActions {...props} /> - <Button type="primary" disabled={!props.canRender} loading={props.rendering} onClick={props.onRender}> - {t('instrumentation.action.render')} - </Button> - </div> - {props.renderError && <Alert type="error" showIcon message={t('instrumentation.v2.renderError')} />} - <TokenModal {...props} /> + {props.phase === 'service' && <ServiceStep {...props} />} + {props.phase === 'destination' && <DestinationStep {...props} />} + {props.phase === 'guide' && <InstrumentationGuideConfiguration {...props} />} + <InstrumentationTokenModal {...props} /> </section> ); } -function TokenConfiguration(props: ConfigureStepProps) { +function ServiceStep(props: ConfigureStepProps) { const { t } = useTranslation(); - if (!props.requiresToken && !props.tokenAcknowledgementRequired) return null; + const ready = serviceConfigurationReady(props.service, props.platform, props.platformOptions); return ( <> - {props.tokenAcknowledgementRequired && ( - <Alert - type="warning" - showIcon - message={t('instrumentation.token.acknowledgementRequired')} - action={ - <Button size="small" onClick={props.onAcknowledgeToken}> - {t('instrumentation.token.acknowledge')} - </Button> - } - /> - )} - {props.requiresToken && selectedProfileUsesPlaintext(props.profiles, props.profileId) && ( - <Alert type="warning" showIcon message={t('instrumentation.token.plaintextBearerWarning')} /> - )} - {props.requiresToken && ( - <InstrumentationTokenField - token={props.token} - disabled={props.tokenAcknowledgementRequired} - onToken={props.onToken} + <section className={styles.configureGroup} aria-labelledby="instrumentation-service-context-title"> + <Typography.Title id="instrumentation-service-context-title" level={5}> + {t('instrumentation.v2.serviceContext')} + </Typography.Title> + <InstrumentationServiceIdentityFields service={props.service} onService={props.onService} /> + <InstrumentationPlatformField + platform={props.platform} + options={props.platformOptions} + onPlatform={props.onPlatform} /> - )} + </section> + <StepActions onPrevious={props.onPrevious}> + <Button type="primary" disabled={!ready} onClick={props.onNext}> + {t('instrumentation.action.next')} + </Button> + </StepActions> </> ); } -function TokenActions(props: ConfigureStepProps) { +function DestinationStep(props: ConfigureStepProps) { const { t } = useTranslation(); - if (!props.requiresToken) return null; + const available = props.profiles.profiles.some( + profile => profile.id === props.profileId && profile.availability === 'available' + ); return ( <> - {props.canGenerateToken && ( - <Button disabled={props.tokenAcknowledgementRequired} onClick={props.onOpenToken}> - {t('instrumentation.token.generateAccess')} + <InstrumentationDestinationSelector + profiles={props.profiles} + profileId={props.profileId} + onProfile={props.onProfile} + /> + <StepActions onPrevious={props.onPrevious}> + <Button type="primary" disabled={!available} onClick={props.onNext}> + {t('instrumentation.action.next')} </Button> - )} - <Typography.Text type={props.token ? 'success' : 'secondary'}> - {t(props.token ? 'instrumentation.token.ready' : 'instrumentation.token.notGenerated')} - </Typography.Text> + </StepActions> </> ); } -function TokenModal(props: ConfigureStepProps) { - if (!props.canGenerateToken || !props.tokenDraft) return null; +function StepActions(props: { onPrevious: () => void; children: React.ReactNode }) { + const { t } = useTranslation(); return ( - <InstrumentationAccessTokenModal - draft={props.tokenDraft} - tokenGenerating={props.tokenGenerating} - tokenError={props.tokenError} - onClose={props.onCloseToken} - onDraft={props.onTokenDraft} - onGenerate={props.onGenerateToken} - /> + <div className={styles.configureActions}> + <Button onClick={props.onPrevious}>{t('instrumentation.action.previous')}</Button> + {props.children} + </div> ); } - -function selectedProfileUsesPlaintext(profiles: IntakeProfilesResponse, profileId: string) { - const profile = profiles.profiles.find(item => item.id === profileId); - return profileUsesPlaintext(profile); -} diff --git a/web-app/src/features/instrumentation/components/instrumentation-configure.module.css b/web-app/src/features/instrumentation/components/instrumentation-configure.module.css index 2ebf909215..09f6471a60 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-configure.module.css +++ b/web-app/src/features/instrumentation/components/instrumentation-configure.module.css @@ -1,7 +1,9 @@ .section { - width: min(100%, 1120px); + width: min(100%, 1060px); + min-height: calc(100dvh - 152px); display: grid; - align-content: start; + grid-template-rows: auto auto minmax(0, 1fr) auto; + align-content: stretch; gap: 24px; } @@ -11,10 +13,7 @@ } .configureIntro, -.configurePrimary, -.configureGroup, -.destinationPanel, -.destinationHeading { +.configureGroup { display: grid; align-content: start; } @@ -34,18 +33,12 @@ gap: 28px; } -.configurePrimary { - gap: 24px; -} - -.configureGroup, -.destinationPanel, -.destinationHeading { +.configureGroup { gap: 12px; } -.destinationHeading { - gap: 4px; +.configureGroup { + width: min(100%, 720px); } .serviceNameField { @@ -75,102 +68,12 @@ margin-top: 10px; } -.destinationList { - display: grid; - grid-template-columns: 1fr; - border-top: 1px solid var(--hb-border); -} - -.destinationOption { - min-height: 96px; - display: grid; - grid-template-columns: minmax(230px, 0.85fr) minmax(260px, 1.15fr) minmax(250px, 1fr); - align-items: center; - gap: 20px; - border: 0; - border-bottom: 1px solid var(--hb-border); - padding: 14px 12px; - background: transparent; - color: inherit; - text-align: left; -} - -.destinationOption:not(:disabled) { - cursor: pointer; -} - -.destinationOption:disabled { - cursor: not-allowed; - color: var(--hb-text-secondary); -} - -.destinationOption:hover:not(:disabled), -.destinationOption:focus-visible { - background: var(--hb-bg-hover); -} - -.destinationOption:focus-visible { - outline: 2px solid var(--hb-focus-ring); - outline-offset: -2px; -} - -.destinationOptionSelected { - box-shadow: inset 2px 0 0 var(--hb-brand-accent); - background: var(--hb-bg-selected); -} - -.destinationIdentity, -.destinationExplanation, -.destinationEndpointList { - display: grid; - align-content: center; - gap: 6px; - min-width: 0; -} - -.destinationIdentity { - grid-template-columns: minmax(0, 1fr) auto; -} - -.destinationRoute { - grid-column: 1 / -1; - color: var(--hb-text-secondary); -} - -.destinationAvailability { - grid-column: 2; - grid-row: 1; - justify-self: end; -} - -.destinationExplanation small, -.destinationEndpointList small, -.destinationSetupHint { - color: var(--hb-text-secondary); -} - -.endpointEvidence { - display: grid; - grid-template-columns: minmax(0, auto) auto minmax(0, 1fr); - align-items: center; - gap: 6px; -} - -.destinationSetupHint { - align-self: center; -} - -.endpointEvidence code { - overflow: hidden; - color: var(--hb-text-secondary); - text-overflow: ellipsis; - white-space: nowrap; -} - .configureActions { display: flex; align-items: center; gap: 10px; + margin-top: auto; + align-self: end; border-top: 1px solid var(--hb-border); padding-top: 16px; } @@ -190,17 +93,6 @@ margin-top: 12px; } -@media (max-width: 920px) { - .destinationOption { - grid-template-columns: minmax(210px, 0.8fr) minmax(0, 1.2fr); - } - - .destinationEndpointList, - .destinationSetupHint { - grid-column: 1 / -1; - } -} - @media (max-width: 760px) { .serviceNameField { grid-template-columns: 1fr; @@ -209,14 +101,4 @@ .serviceNameField > :nth-child(3) { grid-column: 1; } - - .destinationOption { - grid-template-columns: 1fr; - gap: 10px; - } - - .destinationEndpointList, - .destinationSetupHint { - grid-column: 1; - } } diff --git a/web-app/src/features/instrumentation/components/instrumentation-destination-selector.module.css b/web-app/src/features/instrumentation/components/instrumentation-destination-selector.module.css new file mode 100644 index 0000000000..6200d6a035 --- /dev/null +++ b/web-app/src/features/instrumentation/components/instrumentation-destination-selector.module.css @@ -0,0 +1,170 @@ +.destinationWorkspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + align-items: stretch; + gap: 32px; +} + +.destinationPanel { + display: grid; + align-content: start; + gap: 12px; +} + +.destinationList { + display: grid; + border-top: 1px solid var(--hb-border); +} + +.destinationOption { + position: relative; + min-height: 88px; + display: grid; + grid-template-columns: 20px minmax(220px, 0.9fr) minmax(240px, 1.1fr); + align-items: center; + gap: 14px; + border: 0; + border-bottom: 1px solid var(--hb-border); + padding: 14px 12px; + background: transparent; + color: inherit; + text-align: left; +} + +.destinationOption:not(:disabled) { + cursor: pointer; +} +.destinationOption:disabled { + cursor: not-allowed; + color: var(--hb-text-secondary); +} +.destinationOption:hover:not(:disabled), +.destinationOption:focus-visible { + background: var(--hb-bg-hover); +} +.destinationOption:focus-visible { + outline: 2px solid var(--hb-focus-ring); + outline-offset: -2px; +} +.destinationOptionSelected { + box-shadow: inset 2px 0 0 var(--hb-brand-accent); + background: var(--hb-bg-selected); +} + +.destinationRadio { + width: 16px; + height: 16px; + display: grid; + place-items: center; + border: 1px solid var(--ant-color-border); + border-radius: 50%; + background: var(--hb-bg-raised); +} + +.destinationRadio span { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--hb-brand-accent); +} +.destinationIdentity, +.destinationExplanation { + display: grid; + align-content: center; + gap: 6px; + min-width: 0; +} +.destinationIdentity { + grid-template-columns: minmax(0, 1fr) auto; +} +.destinationRoute { + grid-column: 1 / -1; + color: var(--hb-text-secondary); +} +.destinationAvailability { + grid-column: 2; + grid-row: 1; + justify-self: end; +} +.destinationExplanation, +.destinationReason { + color: var(--hb-text-secondary); +} +.destinationReason { + grid-column: 2 / -1; + margin-top: -8px; + padding-bottom: 4px; +} + +.currentIntake { + min-width: 0; + display: grid; + align-content: start; + gap: 14px; + border-left: 1px solid var(--hb-border); + padding-left: 28px; +} + +.currentIntake h5 { + margin: 0; +} +.currentIntakeStatus { + display: flex; + align-items: center; + gap: 7px; + color: var(--ant-color-success); +} +.currentIntakeMeta { + display: grid; + gap: 4px; + color: var(--hb-text-secondary); +} +.currentIntakeMeta code, +.endpointSummary code { + overflow-wrap: anywhere; + color: var(--hb-text-primary); +} +.endpointSummary { + display: grid; + gap: 16px; + margin: 2px 0 0; +} +.endpointSummary > div { + display: grid; + gap: 5px; +} +.endpointSummary dt { + color: var(--hb-text-secondary); + font-size: 12px; +} +.endpointSummary dd { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; + margin: 0; +} + +@media (max-width: 920px) { + .destinationWorkspace { + grid-template-columns: 1fr; + } + .currentIntake { + border-top: 1px solid var(--hb-border); + border-left: 0; + padding-top: 20px; + padding-left: 0; + } +} + +@media (max-width: 760px) { + .destinationOption { + grid-template-columns: 20px minmax(0, 1fr); + gap: 8px 12px; + } + .destinationExplanation, + .destinationReason { + grid-column: 2; + } +} diff --git a/web-app/src/features/instrumentation/components/instrumentation-destination-selector.tsx b/web-app/src/features/instrumentation/components/instrumentation-destination-selector.tsx index d65febaeb7..93833855c4 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-destination-selector.tsx +++ b/web-app/src/features/instrumentation/components/instrumentation-destination-selector.tsx @@ -5,7 +5,8 @@ * The ASF licenses this file to You under the Apache License, Version 2.0. */ -import { Tag, Typography } from 'antd'; +import { CheckCircleFilled, CopyOutlined } from '@ant-design/icons'; +import { Button, Select, Tag, Typography } from 'antd'; import { useTranslation } from 'react-i18next'; import { @@ -15,7 +16,7 @@ import { type IntakeProfileKind } from '../model/intake-profile'; import type { IntakeProfile, IntakeProfilesResponse } from '../model/instrumentation-v2-contract'; -import styles from './instrumentation-configure.module.css'; +import styles from './instrumentation-destination-selector.module.css'; export function InstrumentationDestinationSelector(props: { profiles: IntakeProfilesResponse; @@ -23,29 +24,31 @@ export function InstrumentationDestinationSelector(props: { onProfile: (intakeProfileId: string) => void; }) { const { t } = useTranslation(); + const selectedProfile = props.profiles.profiles.find(profile => profile.id === props.profileId); return ( - <section className={styles.destinationPanel} aria-labelledby="instrumentation-destination-title"> - <div className={styles.destinationHeading}> - <Typography.Title id="instrumentation-destination-title" level={5}> - {t('instrumentation.v2.destination')} - </Typography.Title> - <Typography.Text type="secondary">{t('instrumentation.v2.destinationDescription')}</Typography.Text> - </div> - <div className={styles.destinationList}> - {INTAKE_PROFILE_KINDS.flatMap(kind => { - const profiles = profilesForKind(props.profiles, kind); - if (!profiles.length) return <MissingDestination key={kind} kind={kind} />; - return profiles.map(profile => ( - <DestinationOption - key={profile.id} - profile={profile} - selected={props.profileId === profile.id} - onSelect={props.onProfile} - /> - )); - })} - </div> - </section> + <div className={styles.destinationWorkspace}> + <section className={styles.destinationPanel} aria-label={t('instrumentation.v2.destination')}> + <div className={styles.destinationList}> + {INTAKE_PROFILE_KINDS.map(kind => { + const kindProfiles = profilesForKind(props.profiles, kind); + if (!kindProfiles.length) return <MissingDestination key={kind} kind={kind} />; + const profile = + kindProfiles.find(item => item.id === props.profileId) ?? + kindProfiles.find(item => item.availability === 'available') ?? + kindProfiles[0]!; + return ( + <DestinationOption + key={kind} + profile={profile} + selected={selectedProfile?.kind === kind} + onSelect={props.onProfile} + /> + ); + })} + </div> + </section> + <CurrentIntake profile={selectedProfile} profiles={props.profiles} onProfile={props.onProfile} /> + </div> ); } @@ -64,20 +67,12 @@ function DestinationOption(props: { aria-pressed={props.selected} onClick={() => props.onSelect(props.profile.id)} > + <span className={styles.destinationRadio} aria-hidden="true"> + {props.selected && <span />} + </span> <DestinationIdentity kind={props.profile.kind} available={available} /> <DestinationExplanation kind={props.profile.kind} /> - <span className={styles.destinationEndpointList}> - {intakeEndpointEntries(props.profile).map(([transport, endpoint]) => ( - <span key={transport} className={styles.endpointEvidence}> - <span>{t(`instrumentation.v2.transport.${transport}`)}</span> - <Tag color={endpoint.security === 'plaintext' ? 'error' : 'success'}> - {t(`instrumentation.v2.security.${endpoint.security}`)} - </Tag> - <code>{endpoint.url}</code> - </span> - ))} - {!available && <small>{t(profileReasonKey(props.profile.errorCode))}</small>} - </span> + {!available && <small className={styles.destinationReason}>{t(profileReasonKey(props.profile.errorCode))}</small>} </button> ); } @@ -86,9 +81,10 @@ function MissingDestination({ kind }: { kind: IntakeProfileKind }) { const { t } = useTranslation(); return ( <button type="button" className={styles.destinationOption} disabled aria-pressed={false}> + <span className={styles.destinationRadio} aria-hidden="true" /> <DestinationIdentity kind={kind} available={false} /> <DestinationExplanation kind={kind} /> - <small className={styles.destinationSetupHint}>{t(setupHintKey(kind))}</small> + <small className={styles.destinationReason}>{t(setupHintKey(kind))}</small> </button> ); } @@ -111,12 +107,84 @@ function DestinationExplanation({ kind }: { kind: IntakeProfileKind }) { return ( <span className={styles.destinationExplanation}> <span>{t(`instrumentation.v2.profilePurpose.${kind}`)}</span> - <small>{t(`instrumentation.v2.profileOwnership.${kind}`)}</small> - <small>{t(`instrumentation.v2.profileBoundary.${kind}`)}</small> </span> ); } +function CurrentIntake(props: { + profile: IntakeProfile | undefined; + profiles: IntakeProfilesResponse; + onProfile: (profileId: string) => void; +}) { + const { t } = useTranslation(); + const availablePeers = props.profile + ? profilesForKind(props.profiles, props.profile.kind).filter(item => item.availability === 'available') + : []; + return ( + <aside className={styles.currentIntake} aria-label={t('instrumentation.v2.guided.currentIntake')}> + <Typography.Title level={5}>{t('instrumentation.v2.guided.currentIntake')}</Typography.Title> + {!props.profile || props.profile.availability !== 'available' ? ( + <Typography.Text type="secondary">{t('instrumentation.v2.guided.selectAvailableDestination')}</Typography.Text> + ) : ( + <> + <div className={styles.currentIntakeStatus}> + <CheckCircleFilled aria-hidden="true" /> + <span>{t('instrumentation.v2.guided.online')}</span> + </div> + <strong>{t(`instrumentation.v2.profileKind.${props.profile.kind}`)}</strong> + {availablePeers.length > 1 && ( + <Select + aria-label={t('instrumentation.v2.guided.destinationInstance')} + value={props.profile.id} + options={availablePeers.map(item => ({ value: item.id, label: item.collectorId ?? item.id }))} + onChange={props.onProfile} + /> + )} + {props.profile.collectorId && ( + <span className={styles.currentIntakeMeta}> + <span>{t('instrumentation.v2.guided.collectorId')}</span> + <code>{props.profile.collectorId}</code> + </span> + )} + <EndpointSummary profile={props.profile} /> + </> + )} + </aside> + ); +} + +function EndpointSummary({ profile }: { profile: IntakeProfile }) { + const { t } = useTranslation(); + return ( + <dl className={styles.endpointSummary}> + {intakeEndpointEntries(profile).map(([transport, endpoint]) => ( + <div key={transport}> + <dt>{t(`instrumentation.v2.transport.${transport}`)}</dt> + <dd> + <code>{endpoint.url}</code> + <Tag color={endpoint.security === 'plaintext' ? 'warning' : 'success'}> + {t(`instrumentation.v2.security.${endpoint.security}`)} + </Tag> + <Button + type="text" + size="small" + icon={<CopyOutlined aria-hidden="true" />} + aria-label={t('instrumentation.v2.guided.copyEndpoint', { + transport: t(`instrumentation.v2.transport.${transport}`) + })} + onClick={() => copyEndpoint(endpoint.url)} + /> + </dd> + </div> + ))} + </dl> + ); +} + +function copyEndpoint(value: string) { + void navigator.clipboard?.writeText(value).catch(() => undefined); +} + function setupHintKey(kind: IntakeProfileKind) { if (kind === 'server') return 'instrumentation.v2.serverSetupHint'; if (kind === 'hertzbeat_collector') return 'instrumentation.v2.hybridCollectorSetupHint'; diff --git a/web-app/src/features/instrumentation/components/instrumentation-guide-configuration.module.css b/web-app/src/features/instrumentation/components/instrumentation-guide-configuration.module.css new file mode 100644 index 0000000000..665aa89f80 --- /dev/null +++ b/web-app/src/features/instrumentation/components/instrumentation-guide-configuration.module.css @@ -0,0 +1,68 @@ +.guidePreparation { + width: min(100%, 760px); + display: grid; + gap: 20px; +} + +.tokenSetup { + display: grid; + gap: 14px; +} +.tokenSetupPrompt { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 20px; + padding: 4px 0; +} +.tokenSetupCopy { + display: grid; + gap: 3px; +} +.tokenSetupActions { + display: flex; + align-items: center; + gap: 4px; +} +.cancelExistingToken { + width: fit-content; + padding-inline: 0; +} + +.selectedDestinationInline { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 14px; + border-bottom: 1px solid var(--hb-border); + padding-bottom: 16px; +} + +.selectedDestinationInline > span, +.selectedDestinationInline small { + color: var(--hb-text-secondary); +} +.selectedDestinationInline small { + grid-column: 2; +} +.selectedDestinationEndpoints { + grid-column: 2; + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} +.selectedDestinationEndpoints > span { + display: flex; + gap: 6px; + font-size: 12px; +} + +@media (max-width: 760px) { + .tokenSetupPrompt { + grid-template-columns: 1fr; + gap: 12px; + } + .tokenSetupActions { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/web-app/src/features/instrumentation/components/instrumentation-guide-configuration.tsx b/web-app/src/features/instrumentation/components/instrumentation-guide-configuration.tsx new file mode 100644 index 0000000000..b240f30a5b --- /dev/null +++ b/web-app/src/features/instrumentation/components/instrumentation-guide-configuration.tsx @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { Alert, Button, Typography } from 'antd'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { intakeEndpointEntries, profileUsesPlaintext } from '../model/intake-profile'; +import { InstrumentationAccessTokenModal } from './instrumentation-access-token-modal'; +import { InstrumentationTokenField } from './instrumentation-configuration-fields'; +import configureStyles from './instrumentation-configure.module.css'; +import type { ConfigureStepProps } from './instrumentation-configure-step-props'; +import styles from './instrumentation-guide-configuration.module.css'; + +export function InstrumentationGuideConfiguration(props: ConfigureStepProps) { + const { t } = useTranslation(); + const [usingExistingToken, setUsingExistingToken] = useState(!props.canGenerateToken); + return ( + <> + <div className={styles.guidePreparation}> + <SelectedDestination profiles={props.profiles} profileId={props.profileId} /> + <TokenConfiguration + {...props} + usingExistingToken={usingExistingToken} + onUseExistingToken={() => setUsingExistingToken(true)} + onCancelExistingToken={() => setUsingExistingToken(false)} + /> + </div> + <div className={configureStyles.configureActions}> + <Button onClick={props.onPrevious}>{t('instrumentation.action.previous')}</Button> + <Button type="primary" disabled={!props.canRender} loading={props.rendering} onClick={props.onRender}> + {t('instrumentation.action.next')} + </Button> + </div> + {props.renderError && <Alert type="error" showIcon message={t('instrumentation.v2.renderError')} />} + </> + ); +} + +function SelectedDestination(props: Pick<ConfigureStepProps, 'profiles' | 'profileId'>) { + const { t } = useTranslation(); + const profile = props.profiles.profiles.find(item => item.id === props.profileId); + if (!profile) return null; + return ( + <div className={styles.selectedDestinationInline}> + <span>{t('instrumentation.v2.guided.selectedDestination')}</span> + <strong>{t(`instrumentation.v2.profileKind.${profile.kind}`)}</strong> + <small>{t(`instrumentation.v2.profileRoute.${profile.kind}`)}</small> + <span className={styles.selectedDestinationEndpoints}> + {intakeEndpointEntries(profile).map(([transport, endpoint]) => ( + <span key={transport}> + {t(`instrumentation.v2.transport.${transport}`)} + <Typography.Text type="secondary">{t(`instrumentation.v2.security.${endpoint.security}`)}</Typography.Text> + </span> + ))} + </span> + </div> + ); +} + +function TokenConfiguration( + props: ConfigureStepProps & { + usingExistingToken: boolean; + onUseExistingToken: () => void; + onCancelExistingToken: () => void; + } +) { + const { t } = useTranslation(); + if (!props.requiresToken && !props.tokenAcknowledgementRequired) return null; + const profile = props.profiles.profiles.find(item => item.id === props.profileId); + return ( + <div className={styles.tokenSetup}> + {props.requiresToken && profileUsesPlaintext(profile) && ( + <Alert type="warning" showIcon message={t('instrumentation.token.plaintextBearerWarning')} /> + )} + {props.requiresToken && <TokenSetupPrompt {...props} />} + {props.requiresToken && tokenFieldVisible(props) && ( + <InstrumentationTokenField + token={props.token} + disabled={props.tokenAcknowledgementRequired} + onToken={props.onToken} + /> + )} + {props.requiresToken && props.usingExistingToken && props.canGenerateToken && !props.token && ( + <Button className={styles.cancelExistingToken!} type="link" onClick={props.onCancelExistingToken}> + {t('instrumentation.token.cancelExisting')} + </Button> + )} + </div> + ); +} + +function TokenSetupPrompt( + props: Pick<ConfigureStepProps, 'token' | 'canGenerateToken' | 'onOpenToken'> & { + usingExistingToken: boolean; + onUseExistingToken: () => void; + } +) { + const { t } = useTranslation(); + const copyKey = tokenSetupCopyKey(props); + const showTokenActions = !props.token && !tokenFieldVisible(props); + return ( + <div className={styles.tokenSetupPrompt}> + <span className={styles.tokenSetupCopy}> + <Typography.Text strong>{t(`instrumentation.token.${copyKey}Title`)}</Typography.Text> + <Typography.Text type="secondary">{t(`instrumentation.token.${copyKey}Description`)}</Typography.Text> + </span> + {showTokenActions && ( + <span className={styles.tokenSetupActions}> + <Button type="primary" onClick={props.onOpenToken}> + {t('instrumentation.token.create')} + </Button> + <Button type="link" onClick={props.onUseExistingToken}> + {t('instrumentation.token.useExisting')} + </Button> + </span> + )} + </div> + ); +} + +function tokenSetupCopyKey(props: { token: string; usingExistingToken: boolean; canGenerateToken: boolean }) { + if (props.token) return 'ready'; + if (props.usingExistingToken || !props.canGenerateToken) return 'existing'; + return 'setup'; +} + +function tokenFieldVisible(props: { usingExistingToken: boolean; canGenerateToken: boolean }) { + return props.usingExistingToken || !props.canGenerateToken; +} + +export function InstrumentationTokenModal(props: ConfigureStepProps) { + if (!props.canGenerateToken || !props.tokenDraft) return null; + return ( + <InstrumentationAccessTokenModal + draft={props.tokenDraft} + tokenGenerating={props.tokenGenerating} + tokenError={props.tokenError} + onClose={props.onCloseToken} + onDraft={props.onTokenDraft} + onGenerate={props.onGenerateToken} + /> + ); +} diff --git a/web-app/src/features/instrumentation/components/instrumentation-guide-workspace.tsx b/web-app/src/features/instrumentation/components/instrumentation-guide-workspace.tsx index 736dff6ef8..3eb1b35c32 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-guide-workspace.tsx +++ b/web-app/src/features/instrumentation/components/instrumentation-guide-workspace.tsx @@ -4,7 +4,7 @@ * governing permissions and limitations under the License. */ -import { Button, Typography } from 'antd'; +import { Alert, Button, Typography } from 'antd'; import { useTranslation } from 'react-i18next'; import type { InstrumentationDraft } from '../model/instrumentation-flow'; @@ -26,6 +26,7 @@ export function InstrumentationGuideWorkspace(props: { draft: InstrumentationDraft; guide: RenderResponse; token: string; + tokenAcknowledgementRequired?: boolean; detection?: DetectionResponse; detecting: boolean; detectionError: boolean; @@ -33,10 +34,17 @@ export function InstrumentationGuideWorkspace(props: { onEdit: () => void; onDetect: () => void; onOpen: (signal: Signal) => void; + onAcknowledgeToken?: () => void; }) { return ( <div className={styles.workspace}> - <SelectionSummary catalog={props.catalog} draft={props.draft} onEdit={props.onEdit} /> + {props.tokenAcknowledgementRequired && <TokenAcknowledgement onAcknowledge={props.onAcknowledgeToken} />} + <SelectionSummary + catalog={props.catalog} + draft={props.draft} + editingDisabled={Boolean(props.tokenAcknowledgementRequired)} + onEdit={props.onEdit} + /> <div className={styles.workspaceBody}> <InstrumentationGuideBlocks guide={props.guide} token={props.token} onCopy={props.onCopy} /> <DestinationRail {...props} /> @@ -45,7 +53,28 @@ export function InstrumentationGuideWorkspace(props: { ); } -function SelectionSummary(props: { catalog: CatalogResponse; draft: InstrumentationDraft; onEdit: () => void }) { +function TokenAcknowledgement(props: { onAcknowledge: (() => void) | undefined }) { + const { t } = useTranslation(); + return ( + <Alert + type="warning" + showIcon + message={t('instrumentation.token.guideAcknowledgementRequired')} + action={ + <Button size="small" onClick={() => props.onAcknowledge?.()}> + {t('instrumentation.token.acknowledge')} + </Button> + } + /> + ); +} + +function SelectionSummary(props: { + catalog: CatalogResponse; + draft: InstrumentationDraft; + editingDisabled: boolean; + onEdit: () => void; +}) { const { t } = useTranslation(); const source = props.catalog.sources.find(item => item.id === props.draft.sourceId); const dimensions = ['framework', 'method', 'environment', 'platform'] as const; @@ -53,7 +82,7 @@ function SelectionSummary(props: { catalog: CatalogResponse; draft: Instrumentat <aside className={styles.summary}> <div className={styles.railHeading}> <Typography.Text strong>{t('instrumentation.v2.selection')}</Typography.Text> - <Button size="small" type="link" onClick={props.onEdit}> + <Button size="small" type="link" disabled={props.editingDisabled} onClick={props.onEdit}> {t('common.edit')} </Button> </div> diff --git a/web-app/src/features/instrumentation/components/instrumentation-guided-configuration.test.tsx b/web-app/src/features/instrumentation/components/instrumentation-guided-configuration.test.tsx new file mode 100644 index 0000000000..21ca72d2d1 --- /dev/null +++ b/web-app/src/features/instrumentation/components/instrumentation-guided-configuration.test.tsx @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +import { InstrumentationConfigureStep } from './instrumentation-configure-step'; + +afterEach(cleanup); + +describe('guided instrumentation configuration', () => { + it('keeps service identity as one focused step before destination selection', () => { + const onNext = vi.fn(); + renderConfigure({ phase: 'service', onNext }); + + expect(screen.getByRole('heading', { name: 'instrumentation.v2.guided.serviceTitle' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'instrumentation.field.serviceName' })).toBeVisible(); + expect(screen.queryByRole('region', { name: 'instrumentation.v2.destination' })).toBeNull(); + expect(screen.queryByLabelText('instrumentation.field.token')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'instrumentation.action.next' })); + expect(onNext).toHaveBeenCalledOnce(); + }); + + it('uses compact route rows and moves endpoint detail into the selected-route summary', () => { + const onProfile = vi.fn(); + renderConfigure({ phase: 'destination', onProfile }); + + expect(screen.getByRole('heading', { name: 'instrumentation.v2.guided.destinationTitle' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'instrumentation.v2.destination' })).toBeVisible(); + expect(screen.getByRole('complementary', { name: 'instrumentation.v2.guided.currentIntake' })).toBeVisible(); + expect(screen.getAllByText('http://127.0.0.1:24318')).toHaveLength(1); + expect(screen.getAllByText('http://127.0.0.1:24317')).toHaveLength(1); + expect(screen.getAllByRole('button', { name: /instrumentation\.v2\.profileKind\./ })).toHaveLength(3); + expect(screen.queryByText('instrumentation.v2.profileOwnership.hertzbeat_collector')).toBeNull(); + expect(screen.queryByText('instrumentation.v2.profileBoundary.hertzbeat_collector')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: /instrumentation\.v2\.profileKind\.hertzbeat_collector/ })); + expect(onProfile).toHaveBeenCalledWith('collector:hybrid-local-0812'); + }); + + it('guides token creation before continuing and keeps existing-token entry secondary', () => { + const onRender = vi.fn(); + const onOpenToken = vi.fn(); + const onToken = vi.fn(); + const view = renderConfigure({ phase: 'guide', token: '', canRender: false, onOpenToken, onRender, onToken }); + + expect(screen.getByRole('heading', { name: 'instrumentation.v2.guided.guideTitle' })).toBeVisible(); + expect(screen.queryByLabelText('instrumentation.field.token')).toBeNull(); + expect(screen.queryByRole('region', { name: 'instrumentation.v2.destination' })).toBeNull(); + expect(screen.getByRole('button', { name: 'instrumentation.token.create' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'instrumentation.token.useExisting' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'instrumentation.action.next' })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'instrumentation.token.create' })); + expect(onOpenToken).toHaveBeenCalledOnce(); + + fireEvent.click(screen.getByRole('button', { name: 'instrumentation.token.useExisting' })); + const tokenInput = screen.getByLabelText('instrumentation.field.token'); + fireEvent.change(tokenInput, { target: { value: 'existing-token' } }); + expect(onToken).toHaveBeenCalledWith('existing-token'); + + view.unmount(); + renderConfigure({ + phase: 'guide', + token: 'generated-token', + canRender: true, + tokenAcknowledgementRequired: true, + onRender + }); + expect(screen.queryByLabelText('instrumentation.field.token')).toBeNull(); + expect(screen.getByText('instrumentation.token.readyTitle')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'instrumentation.token.create' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'instrumentation.token.useExisting' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'instrumentation.action.next' })); + expect(onRender).toHaveBeenCalledOnce(); + }); +}); + +type ConfigureOverrides = Partial<React.ComponentProps<typeof InstrumentationConfigureStep>>; + +function renderConfigure(overrides: ConfigureOverrides) { + return render(createConfigureStep(overrides)); +} + +function createConfigureStep(overrides: ConfigureOverrides) { + const props: React.ComponentProps<typeof InstrumentationConfigureStep> = { + phase: 'service', + profiles: { + schemaVersion: 2, + status: 'available', + defaultProfileId: 'collector:hybrid-local-0812', + profiles: [ + { + id: 'server', + kind: 'server', + availability: 'unavailable', + supportedTransports: [], + endpoints: {}, + authorizationHeader: null, + errorCode: 'intake_profile_not_advertised' + }, + { + id: 'collector:hybrid-local-0812', + kind: 'hertzbeat_collector', + availability: 'available', + supportedTransports: ['http_protobuf', 'grpc'], + endpoints: { + http_protobuf: { url: 'http://127.0.0.1:24318', security: 'plaintext' }, + grpc: { url: 'http://127.0.0.1:24317', security: 'plaintext' } + }, + authentication: 'bearer_token', + authorizationHeader: 'Authorization', + collectorId: 'hybrid-local-0812' + }, + { + id: 'collector:stale', + kind: 'hertzbeat_collector', + availability: 'unavailable', + supportedTransports: [], + endpoints: {}, + authorizationHeader: null, + errorCode: 'intake_profile_not_advertised' + } + ] + }, + profileId: 'collector:hybrid-local-0812', + service: { name: 'checkout', namespace: 'default', environment: 'production' }, + platformOptions: [], + canRender: true, + rendering: false, + renderError: false, + token: 'test-token-value', + tokenGenerating: false, + tokenError: false, + tokenAcknowledgementRequired: false, + requiresToken: true, + canGenerateToken: true, + onProfile: vi.fn(), + onService: vi.fn(), + onPlatform: vi.fn(), + onToken: vi.fn(), + onRender: vi.fn(), + onPrevious: vi.fn(), + onNext: vi.fn(), + onOpenToken: vi.fn(), + onCloseToken: vi.fn(), + onTokenDraft: vi.fn(), + onGenerateToken: vi.fn(), + onAcknowledgeToken: vi.fn(), + ...overrides + }; + return <InstrumentationConfigureStep {...props} />; +} diff --git a/web-app/src/features/instrumentation/components/instrumentation-onboarding.module.css b/web-app/src/features/instrumentation/components/instrumentation-onboarding.module.css index 3d972059df..2611db0d08 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-onboarding.module.css +++ b/web-app/src/features/instrumentation/components/instrumentation-onboarding.module.css @@ -2,7 +2,7 @@ min-height: 100dvh; width: 100%; display: grid; - grid-template-rows: auto auto 1fr; + grid-template-rows: auto 1fr; background: var(--hb-bg-canvas); } @@ -37,16 +37,23 @@ justify-self: end; } -.progressBand { - border-bottom: 1px solid var(--hb-border); - padding: 22px 32px; +.onboardingBody { + width: min(100%, 1440px); + min-height: 0; + display: grid; + grid-template-columns: 190px minmax(0, 1fr); + margin: 0 auto; +} + +.flowRail { + border-right: 1px solid var(--hb-border); + padding: 34px 22px; background: var(--hb-bg-chrome); } .onboardingContent { - width: min(100%, 1384px); - margin: 0 auto; - padding: 32px; + min-width: 0; + padding: 34px 40px 28px; } .sourceStage { @@ -76,7 +83,17 @@ justify-self: stretch; } - .progressBand, + .onboardingBody { + grid-template-columns: 1fr; + } + + .flowRail { + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--hb-border); + padding: 16px; + } + .onboardingContent { padding: 20px 16px; } diff --git a/web-app/src/features/instrumentation/components/instrumentation-progress.tsx b/web-app/src/features/instrumentation/components/instrumentation-progress.tsx index 11e13f04ab..deafa0edce 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-progress.tsx +++ b/web-app/src/features/instrumentation/components/instrumentation-progress.tsx @@ -7,17 +7,28 @@ import { Steps } from 'antd'; import { useTranslation } from 'react-i18next'; -import { INSTRUMENTATION_STAGES, type InstrumentationStage } from '../model/instrumentation-flow'; +import { + INSTRUMENTATION_CONFIGURE_PHASES, + type InstrumentationConfigurePhase +} from '../model/instrumentation-guided-flow'; +import type { InstrumentationStage } from '../model/instrumentation-flow'; import styles from './instrumentation-shell.module.css'; -export function InstrumentationProgress(props: { stage: InstrumentationStage }) { +const GUIDED_STEPS = ['source', ...INSTRUMENTATION_CONFIGURE_PHASES] as const; + +export function InstrumentationProgress(props: { + stage: InstrumentationStage; + configurePhase: InstrumentationConfigurePhase; +}) { const { t } = useTranslation(); + const current = props.stage === 'source' ? 0 : GUIDED_STEPS.indexOf(props.configurePhase); return ( - <div className={styles.progress}> + <div className={styles.guidedProgress}> <Steps size="small" - current={INSTRUMENTATION_STAGES.indexOf(props.stage)} - items={INSTRUMENTATION_STAGES.map(stage => ({ title: t(`instrumentation.v2.stage.${stage}`) }))} + direction="vertical" + current={current} + items={GUIDED_STEPS.map(step => ({ title: t(`instrumentation.v2.guided.stage.${step}`) }))} /> </div> ); diff --git a/web-app/src/features/instrumentation/components/instrumentation-shell.module.css b/web-app/src/features/instrumentation/components/instrumentation-shell.module.css index e8482c68a7..dd032cf1c9 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-shell.module.css +++ b/web-app/src/features/instrumentation/components/instrumentation-shell.module.css @@ -1,10 +1,20 @@ -.progress { - max-width: 1320px; - margin: 0 auto; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 16px; +.guidedProgress { + position: sticky; + top: 24px; +} + +.guidedProgress :global(.ant-steps-item-title) { + color: var(--hb-text-secondary) !important; + font-size: 13px; +} + +.guidedProgress :global(.ant-steps-item-active .ant-steps-item-title) { + color: var(--hb-text-primary) !important; + font-weight: 600; +} + +.guidedProgress :global(.ant-steps-item-description) { + display: none; } .section h4 { @@ -43,12 +53,12 @@ .sourceTile { min-width: max-content; - min-height: 44px; + min-height: 36px; display: flex; flex: 0 0 auto; align-items: center; - gap: 10px; - padding: 9px 14px; + gap: 8px; + padding: 6px 10px; border: var(--ant-line-width) solid var(--hb-border); border-radius: var(--hb-radius-control); background: var(--hb-bg-raised); @@ -91,11 +101,11 @@ } .sourceIcon { - width: var(--ant-font-size-lg); - height: var(--ant-font-size-lg); + width: var(--ant-font-size); + height: var(--ant-font-size); flex: 0 0 auto; object-fit: contain; - font-size: var(--ant-font-size-lg); + font-size: var(--ant-font-size); color: var(--hb-brand-accent); } @@ -153,8 +163,9 @@ .categoryActionSelected, .categoryActionSelected:hover { + border-radius: 0; color: var(--hb-text-primary) !important; - background: var(--hb-bg-selected) !important; + background: transparent !important; box-shadow: inset 2px 0 0 var(--hb-brand-accent); } @@ -174,6 +185,22 @@ color: var(--hb-status-degraded); } +@media (max-width: 760px) { + .guidedProgress { + position: static; + min-width: 620px; + } + + .guidedProgress :global(.ant-steps) { + display: flex; + flex-direction: row; + } + + .guidedProgress :global(.ant-steps-item) { + flex: 1; + } +} + .sourceAssistiveText { position: absolute; width: 1px; diff --git a/web-app/src/features/instrumentation/components/instrumentation-v2-flow.test.tsx b/web-app/src/features/instrumentation/components/instrumentation-v2-flow.test.tsx index 1301beeff5..8ad7868a1c 100644 --- a/web-app/src/features/instrumentation/components/instrumentation-v2-flow.test.tsx +++ b/web-app/src/features/instrumentation/components/instrumentation-v2-flow.test.tsx @@ -25,6 +25,7 @@ import { InstrumentationGuideBlocks } from './instrumentation-guide-blocks'; import { InstrumentationGuideWorkspace } from './instrumentation-guide-workspace'; import { InstrumentationSourceStep } from './instrumentation-source-step'; import configureCss from './instrumentation-configure.module.css?raw'; +import destinationCss from './instrumentation-destination-selector.module.css?raw'; import guideCss from './instrumentation-guide.module.css?raw'; import questionCss from './instrumentation-question.module.css?raw'; import shellCss from './instrumentation-shell.module.css?raw'; @@ -53,8 +54,9 @@ describe('instrumentation v2 interaction', () => { expect(screen.getByRole('button', { name: /^instrumentation\.v2\.directory\.source\.logstash/ })).toBeVisible(); expect(shellCss).toMatch(/\.sourceGrid\s*\{[^}]*display:\s*flex[^}]*flex-wrap:\s*wrap/); expect(shellCss).toMatch( - /\.sourceTile\s*\{[^}]*min-width:\s*max-content[^}]*min-height:\s*44px[^}]*flex:\s*0\s+0\s+auto/ + /\.sourceTile\s*\{[^}]*min-width:\s*max-content[^}]*min-height:\s*36px[^}]*flex:\s*0\s+0\s+auto/ ); + expect(shellCss).toMatch(/\.sourceTile\s*\{[^}]*gap:\s*8px[^}]*padding:\s*6px 10px/); expect(shellCss).toMatch( /\.sourceTile\s*\{[^}]*border:\s*var\(--ant-line-width\) solid var\(--hb-border\)[^}]*background:\s*var\(--hb-bg-raised\)/ ); @@ -67,8 +69,12 @@ describe('instrumentation v2 interaction', () => { ); expect(shellCss).toMatch(/\.sourceName\s*\{[^}]*font-weight:\s*600[^}]*white-space:\s*nowrap/); expect(shellCss).toMatch( - /\.sourceIcon\s*\{[^}]*width:\s*var\(--ant-font-size-lg\)[^}]*height:\s*var\(--ant-font-size-lg\)/ + /\.sourceIcon\s*\{[^}]*width:\s*var\(--ant-font-size\)[^}]*height:\s*var\(--ant-font-size\)/ ); + expect(shellCss).toMatch( + /\.categoryActionSelected,[\s\S]*?\{[^}]*border-radius:\s*0[^}]*background:\s*transparent[^}]*box-shadow:\s*inset 2px 0 0 var\(--hb-brand-accent\)/ + ); + expect(shellCss).not.toMatch(/\.categoryActionSelected,[\s\S]*?\{[^}]*background:\s*var\(--hb-bg-selected\)/); const assistiveDescription = within(javaSource).getByText('instrumentation.v2.directory.source.java_description'); expect(assistiveDescription).toHaveClass(/sourceAssistiveText/); expect(within(assistiveDescription).getByText(/instrumentation\.signal\.metrics/)).toBeInTheDocument(); @@ -143,7 +149,25 @@ describe('instrumentation v2 interaction', () => { }); it('explains all three telemetry routes and keeps missing destinations visible without inventing endpoints', () => { + const availableProfiles = { + schemaVersion: 2 as const, + status: 'available' as const, + defaultProfileId: 'server-default', + profiles: [ + guide.intakeProfile, + { + id: 'hybrid-edge', + kind: 'hertzbeat_collector' as const, + availability: 'unavailable' as const, + supportedTransports: [], + endpoints: {}, + authorizationHeader: null, + errorCode: 'intake_profile_unavailable' + } + ] + }; const props = { + phase: 'destination' as const, profileId: '', service: { name: '', @@ -168,52 +192,33 @@ describe('instrumentation v2 interaction', () => { onPlatform: vi.fn(), onToken: vi.fn(), onRender: vi.fn(), + onPrevious: vi.fn(), + onNext: vi.fn(), onOpenToken: vi.fn(), onCloseToken: vi.fn(), onTokenDraft: vi.fn(), onGenerateToken: vi.fn(), onAcknowledgeToken: vi.fn() }; - const view = render( - <InstrumentationConfigureStep - profiles={{ - schemaVersion: 2, - status: 'available', - defaultProfileId: 'server-default', - profiles: [ - guide.intakeProfile, - { - id: 'hybrid-edge', - kind: 'hertzbeat_collector', - availability: 'unavailable', - supportedTransports: [], - endpoints: {}, - authorizationHeader: null, - errorCode: 'intake_profile_unavailable' - } - ] - }} - {...props} - /> - ); - expect(screen.getByRole('heading', { name: 'instrumentation.v2.configureTitle', level: 3 })).toBeVisible(); - expect(screen.getByRole('region', { name: 'instrumentation.v2.serviceContext' })).toBeVisible(); + const view = render(<InstrumentationConfigureStep profiles={availableProfiles} {...props} />); + expect(screen.getByRole('heading', { name: 'instrumentation.v2.guided.destinationTitle', level: 3 })).toBeVisible(); expect(screen.getByRole('region', { name: 'instrumentation.v2.destination' })).toBeVisible(); - expect(screen.getByText('instrumentation.v2.destinationDescription')).toBeVisible(); + expect(screen.getByText('instrumentation.v2.guided.destinationDescription')).toBeVisible(); expect(screen.getByText('instrumentation.v2.profileRoute.server')).toBeVisible(); expect(screen.getByText('instrumentation.v2.profileRoute.hertzbeat_collector')).toBeVisible(); expect(screen.getByText('instrumentation.v2.profileRoute.external_otel_collector')).toBeVisible(); expect(screen.getByText('instrumentation.v2.profilePurpose.server')).toBeVisible(); expect(screen.getByText('instrumentation.v2.profilePurpose.hertzbeat_collector')).toBeVisible(); expect(screen.getByText('instrumentation.v2.profilePurpose.external_otel_collector')).toBeVisible(); - expect(screen.getByText('instrumentation.v2.profileBoundary.server')).toBeVisible(); - expect(screen.getByText('instrumentation.v2.profileBoundary.hertzbeat_collector')).toBeVisible(); - expect(screen.getByText('instrumentation.v2.profileBoundary.external_otel_collector')).toBeVisible(); - expect(configureCss).toMatch(/\.destinationList\s*\{[^}]*display:\s*grid/); - expect(configureCss).toMatch( + expect(screen.queryByText('instrumentation.v2.profileBoundary.server')).toBeNull(); + expect(screen.queryByText('instrumentation.v2.profileBoundary.hertzbeat_collector')).toBeNull(); + expect(screen.queryByText('instrumentation.v2.profileBoundary.external_otel_collector')).toBeNull(); + expect(destinationCss).toMatch(/\.destinationList\s*\{[^}]*display:\s*grid/); + expect(destinationCss).toMatch( /\.destinationAvailability\s*\{[^}]*grid-column:\s*2[^}]*grid-row:\s*1[^}]*justify-self:\s*end/ ); expect(configureCss).toMatch(/\.configureActions\s*\{[^}]*border-top:\s*1px solid var\(--hb-border\)/); + view.rerender(<InstrumentationConfigureStep profiles={availableProfiles} {...props} phase="service" />); const serviceName = screen.getByRole('textbox', { name: 'instrumentation.field.serviceName' }); const serviceEnvironment = screen.getByRole('textbox', { name: 'instrumentation.field.serviceEnvironment' @@ -239,6 +244,7 @@ describe('instrumentation v2 interaction', () => { expect(props.onService).toHaveBeenNthCalledWith(4, { serviceInstanceId: 'checkout-7d9' }); expect(props.onService).toHaveBeenNthCalledWith(5, { endpoint: '/checkout' }); expect(screen.queryByText(/entity/iu)).toBeNull(); + view.rerender(<InstrumentationConfigureStep profiles={availableProfiles} {...props} />); expect(screen.getByRole('button', { name: /instrumentation\.v2\.profileKind\.server/ })).toBeEnabled(); expect( screen.getByRole('button', { name: /instrumentation\.v2\.profileKind\.hertzbeat_collector/ }) @@ -279,6 +285,7 @@ describe('instrumentation v2 interaction', () => { const onGenerateToken = vi.fn(); render( <InstrumentationConfigureStep + phase="guide" profiles={{ schemaVersion: 2, status: 'available', @@ -303,6 +310,8 @@ describe('instrumentation v2 interaction', () => { onPlatform={vi.fn()} onToken={vi.fn()} onRender={vi.fn()} + onPrevious={vi.fn()} + onNext={vi.fn()} onOpenToken={onOpenToken} onCloseToken={vi.fn()} onTokenDraft={vi.fn()} @@ -325,6 +334,7 @@ describe('instrumentation v2 interaction', () => { const onToken = vi.fn(); render( <InstrumentationConfigureStep + phase="guide" profiles={{ schemaVersion: 2, status: 'available', @@ -348,6 +358,8 @@ describe('instrumentation v2 interaction', () => { onPlatform={vi.fn()} onToken={onToken} onRender={vi.fn()} + onPrevious={vi.fn()} + onNext={vi.fn()} onOpenToken={vi.fn()} onCloseToken={vi.fn()} onTokenDraft={vi.fn()} @@ -365,6 +377,7 @@ describe('instrumentation v2 interaction', () => { it('shows endpoint security and an explicit Bearer risk for a selected plaintext destination', () => { render( <InstrumentationConfigureStep + phase="guide" profiles={{ schemaVersion: 2, status: 'available', @@ -395,6 +408,8 @@ describe('instrumentation v2 interaction', () => { onPlatform={vi.fn()} onToken={vi.fn()} onRender={vi.fn()} + onPrevious={vi.fn()} + onNext={vi.fn()} onOpenToken={vi.fn()} onCloseToken={vi.fn()} onTokenDraft={vi.fn()} @@ -410,6 +425,7 @@ describe('instrumentation v2 interaction', () => { it('shows an unauthenticated destination without token controls, status, or Bearer warnings', () => { render( <InstrumentationConfigureStep + phase="guide" profiles={{ schemaVersion: 2, status: 'available', @@ -433,6 +449,8 @@ describe('instrumentation v2 interaction', () => { onPlatform={vi.fn()} onToken={vi.fn()} onRender={vi.fn()} + onPrevious={vi.fn()} + onNext={vi.fn()} onOpenToken={vi.fn()} onCloseToken={vi.fn()} onTokenDraft={vi.fn()} @@ -445,10 +463,11 @@ describe('instrumentation v2 interaction', () => { expect(screen.queryByText('instrumentation.token.notGenerated')).not.toBeInTheDocument(); expect(screen.queryByText('instrumentation.token.plaintextBearerWarning')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'instrumentation.token.generateAccess' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'instrumentation.action.render' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'instrumentation.action.next' })).toBeEnabled(); }); it('materializes the memory-only token visibly without mutating the backend guide', () => { + const onAcknowledgeToken = vi.fn(); render( <InstrumentationGuideWorkspace catalog={catalog} @@ -461,6 +480,7 @@ describe('instrumentation v2 interaction', () => { }} guide={guide} token="valid-token-123" + tokenAcknowledgementRequired detection={detection} detecting={false} detectionError={false} @@ -468,8 +488,13 @@ describe('instrumentation v2 interaction', () => { onEdit={vi.fn()} onDetect={vi.fn()} onOpen={vi.fn()} + onAcknowledgeToken={onAcknowledgeToken} /> ); + expect(screen.getByText('instrumentation.token.guideAcknowledgementRequired')).toBeVisible(); + expect(screen.getByRole('button', { name: 'common.edit' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'instrumentation.token.acknowledge' })); + expect(onAcknowledgeToken).toHaveBeenCalledOnce(); expect(screen.getByText('token=valid-token-123')).toBeVisible(); expect(screen.getByText('https://example.test/otlp')).toBeVisible(); expect(screen.getByText('instrumentation.v2.security.tls')).toBeVisible(); diff --git a/web-app/src/features/instrumentation/instrumentation-i18n.test.ts b/web-app/src/features/instrumentation/instrumentation-i18n.test.ts index b289164475..209d09152b 100644 --- a/web-app/src/features/instrumentation/instrumentation-i18n.test.ts +++ b/web-app/src/features/instrumentation/instrumentation-i18n.test.ts @@ -129,6 +129,15 @@ describe('instrumentation v2 locale contract', () => { expect(loadError, localeName).not.toContain('instrumentation.v2'); }); }); + + it('uses short, task-oriented labels for the guided intake steps', () => { + expect(en.instrumentation.v2.guided.stage).toEqual({ + source: 'Source', + service: 'Service details', + destination: 'Intake path', + guide: 'Setup guide' + }); + }); }); function flatten(value: Record<string, unknown>, prefix = ''): string[] { diff --git a/web-app/src/features/instrumentation/model/instrumentation-flow.ts b/web-app/src/features/instrumentation/model/instrumentation-flow.ts index d47fb0cec2..5ee96d6b56 100644 --- a/web-app/src/features/instrumentation/model/instrumentation-flow.ts +++ b/web-app/src/features/instrumentation/model/instrumentation-flow.ts @@ -28,7 +28,7 @@ export type InstrumentationDraft = Omit<Selection, 'sourceKind'> & { }; export type ApplicationQuestion = 'framework' | 'method' | 'environment' | 'platform'; export type InstrumentationStage = 'source' | 'configure'; -export const INSTRUMENTATION_STAGES: InstrumentationStage[] = ['source', 'configure']; +const INSTRUMENTATION_STAGES: InstrumentationStage[] = ['source', 'configure']; export function previousInstrumentationStage(stage: InstrumentationStage): InstrumentationStage { const index = INSTRUMENTATION_STAGES.indexOf(stage); diff --git a/web-app/src/features/instrumentation/model/instrumentation-guided-flow.ts b/web-app/src/features/instrumentation/model/instrumentation-guided-flow.ts new file mode 100644 index 0000000000..ea448fcf37 --- /dev/null +++ b/web-app/src/features/instrumentation/model/instrumentation-guided-flow.ts @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import type { ServiceIdentity } from './instrumentation-v2-contract'; + +export const INSTRUMENTATION_CONFIGURE_PHASES = ['service', 'destination', 'guide'] as const; +export type InstrumentationConfigurePhase = (typeof INSTRUMENTATION_CONFIGURE_PHASES)[number]; + +export function serviceConfigurationReady( + service: ServiceIdentity, + platform: string | undefined, + platformOptions: string[] +) { + return Boolean( + service.name.trim() && + service.namespace.trim() && + service.environment.trim() && + (platformOptions.length <= 1 || platform) + ); +} diff --git a/web-app/src/features/instrumentation/pages/instrumentation-page.test.tsx b/web-app/src/features/instrumentation/pages/instrumentation-page.test.tsx index ecf6f3a712..b7b7291709 100644 --- a/web-app/src/features/instrumentation/pages/instrumentation-page.test.tsx +++ b/web-app/src/features/instrumentation/pages/instrumentation-page.test.tsx @@ -53,7 +53,7 @@ afterEach(() => { }); describe('InstrumentationPage immersive onboarding shell', () => { - it('owns an accessible HertzBeat header, exit action, and two-step progress without a context stage', () => { + it('owns an accessible HertzBeat header, exit action, and four focused workflow steps', () => { render( <MemoryRouter initialEntries={['/observability/integration']}> <Routes> @@ -65,8 +65,7 @@ describe('InstrumentationPage immersive onboarding shell', () => { expect(screen.getByRole('img', { name: 'HertzBeat' })).toHaveAttribute('src', '/assets/logo.svg'); expect(screen.getByText('HertzBeat')).toBeVisible(); - expect(screen.getAllByText(/^instrumentation\.v2\.stage\./)).toHaveLength(2); - expect(screen.queryByText('instrumentation.v2.stage.context')).toBeNull(); + expect(screen.getAllByText(/^instrumentation\.v2\.guided\.stage\./)).toHaveLength(4); expect(styles).toMatch(/\.onboardingHeader\s*\{[^}]*position:\s*(?!fixed)/); fireEvent.click(screen.getByRole('button', { name: 'instrumentation.action.exit' })); diff --git a/web-app/src/features/instrumentation/pages/instrumentation-page.tsx b/web-app/src/features/instrumentation/pages/instrumentation-page.tsx index e9025377ef..ebec5edab3 100644 --- a/web-app/src/features/instrumentation/pages/instrumentation-page.tsx +++ b/web-app/src/features/instrumentation/pages/instrumentation-page.tsx @@ -6,22 +6,36 @@ import { ArrowLeftOutlined } from '@ant-design/icons'; import { Button } from 'antd'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { applicationRoutePaths } from '@/shared/navigation/app-paths'; -import { InstrumentationConfigureStep } from '../components/instrumentation-configure-step'; -import { InstrumentationGuideWorkspace } from '../components/instrumentation-guide-workspace'; import { InstrumentationInitializationEvidence } from '../components/instrumentation-initialization-evidence'; import { InstrumentationProgress } from '../components/instrumentation-progress'; -import { InstrumentationSourceStep } from '../components/instrumentation-source-step'; import styles from '../components/instrumentation-onboarding.module.css'; import { useInstrumentationPageController } from '../controller/use-instrumentation-page-controller'; +import type { InstrumentationConfigurePhase } from '../model/instrumentation-guided-flow'; +import { InstrumentationStageContent } from './instrumentation-stage-content'; export function InstrumentationPage() { const { t } = useTranslation(); const navigate = useNavigate(); const page = useInstrumentationPageController(); + const [configurePhase, setConfigurePhase] = useState<InstrumentationConfigurePhase>('service'); + const hasLocalBack = page.stage === 'configure' && configurePhase !== 'service'; + const hasBack = page.hasFlowBack || hasLocalBack; + const goBack = () => { + if (page.stage !== 'configure' || configurePhase === 'service') { + page.goBack(); + return; + } + setConfigurePhase(configurePhase === 'guide' ? 'destination' : 'service'); + }; + const reset = () => { + setConfigurePhase('service'); + page.reset(); + }; return ( <div className={styles.page}> <header className={styles.onboardingHeader}> @@ -30,116 +44,36 @@ export function InstrumentationPage() { type="text" icon={<ArrowLeftOutlined aria-hidden="true" />} disabled={page.tokenAcknowledgementRequired} - onClick={() => (page.hasFlowBack ? page.goBack() : void navigate(applicationRoutePaths.dashboard))} + onClick={() => (hasBack ? goBack() : void navigate(applicationRoutePaths.dashboard))} > - {t(page.hasFlowBack ? 'common.back' : 'instrumentation.action.exit')} + {t(hasBack ? 'common.back' : 'instrumentation.action.exit')} </Button> <div className={styles.brand}> <img src="/assets/logo.svg" alt="HertzBeat" width={24} height={23} /> <strong>HertzBeat</strong> </div> - <Button className={styles.startOver!} disabled={page.tokenAcknowledgementRequired} onClick={page.reset}> + <Button className={styles.startOver!} disabled={page.tokenAcknowledgementRequired} onClick={reset}> {t('instrumentation.v2.startOver')} </Button> </header> - <div className={styles.progressBand}> - <InstrumentationProgress stage={page.stage} /> + <div className={styles.onboardingBody}> + <aside className={styles.flowRail} aria-label={t('instrumentation.progress')}> + <InstrumentationProgress stage={page.stage} configurePhase={configurePhase} /> + </aside> + <main className={styles.onboardingContent}> + <InstrumentationInitializationEvidence + catalogState={page.catalogState} + profilesState={page.profilesState} + retrying={page.initializationRetrying} + onRetry={() => void page.retryInitialization()} + /> + <InstrumentationStageContent + page={page} + configurePhase={configurePhase} + onConfigurePhase={setConfigurePhase} + /> + </main> </div> - <main className={styles.onboardingContent}> - <InstrumentationInitializationEvidence - catalogState={page.catalogState} - profilesState={page.profilesState} - retrying={page.initializationRetrying} - onRetry={() => void page.retryInitialization()} - /> - <InstrumentationStageContent page={page} /> - </main> </div> ); } - -type PageController = ReturnType<typeof useInstrumentationPageController>; - -function InstrumentationStageContent({ page }: { page: PageController }) { - if (page.stage === 'source') return <SourceStage page={page} />; - return <ConfigureStage page={page} />; -} - -function SourceStage({ page }: { page: PageController }) { - const { t } = useTranslation(); - if (!page.catalog) return null; - return ( - <div className={styles.sourceStage}> - <InstrumentationSourceStep - key={page.sourceDirectoryRevision} - catalog={page.catalog} - {...(page.draft.sourceId ? { sourceId: page.draft.sourceId } : {})} - {...(page.draft.recipeId ? { recipeId: page.draft.recipeId } : {})} - {...(page.draft.framework ? { framework: page.draft.framework } : {})} - {...(page.draft.method ? { method: page.draft.method } : {})} - {...(page.draft.environment ? { environment: page.draft.environment } : {})} - {...(page.draft.platform ? { platform: page.draft.platform } : {})} - onSource={page.chooseSource} - onApplicationAnswer={page.answerApplication} - /> - <Button - className={styles.sourceContinue!} - type="primary" - disabled={!page.canContinueSource} - onClick={() => page.setStage('configure')} - > - {t('instrumentation.action.continue')} - </Button> - </div> - ); -} - -function ConfigureStage({ page }: { page: PageController }) { - if (!page.profiles || !page.catalog) return null; - return ( - <> - <InstrumentationConfigureStep - profiles={page.profiles} - profileId={page.draft.intakeProfileId} - service={page.draft.service} - platform={page.draft.platform} - platformOptions={page.platformOptions} - canRender={page.canRender} - rendering={page.rendering} - renderError={page.renderError} - token={page.token} - tokenDraft={page.tokenDraft} - tokenGenerating={page.tokenGenerating} - tokenError={page.tokenError} - tokenAcknowledgementRequired={page.tokenAcknowledgementRequired} - requiresToken={page.requiresToken} - canGenerateToken={page.canGenerateToken} - onProfile={intakeProfileId => page.patchDraft({ intakeProfileId })} - onService={page.patchService} - onPlatform={platform => page.patchDraft({ platform })} - onToken={page.setToken} - onRender={() => void page.renderGuide()} - onOpenToken={page.openTokenGenerator} - onCloseToken={page.closeTokenGenerator} - onTokenDraft={page.updateTokenDraft} - onGenerateToken={() => void page.generateToken()} - onAcknowledgeToken={page.acknowledgeGeneratedToken} - /> - {page.guide && ( - <InstrumentationGuideWorkspace - catalog={page.catalog} - draft={page.draft} - guide={page.guide} - token={page.token} - onCopy={page.copyBlock} - detecting={page.detecting} - detectionError={page.detectionError} - {...(page.detection ? { detection: page.detection } : {})} - onEdit={page.goBack} - onDetect={() => void page.detect()} - onOpen={page.openQuery} - /> - )} - </> - ); -} diff --git a/web-app/src/features/instrumentation/pages/instrumentation-stage-content.tsx b/web-app/src/features/instrumentation/pages/instrumentation-stage-content.tsx new file mode 100644 index 0000000000..2e13bc9833 --- /dev/null +++ b/web-app/src/features/instrumentation/pages/instrumentation-stage-content.tsx @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { Button } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { InstrumentationConfigureStep } from '../components/instrumentation-configure-step'; +import { InstrumentationGuideWorkspace } from '../components/instrumentation-guide-workspace'; +import { InstrumentationSourceStep } from '../components/instrumentation-source-step'; +import styles from '../components/instrumentation-onboarding.module.css'; +import type { useInstrumentationPageController } from '../controller/use-instrumentation-page-controller'; +import type { InstrumentationConfigurePhase } from '../model/instrumentation-guided-flow'; + +type PageController = ReturnType<typeof useInstrumentationPageController>; +type StageProps = { + page: PageController; + configurePhase: InstrumentationConfigurePhase; + onConfigurePhase: (phase: InstrumentationConfigurePhase) => void; +}; + +export function InstrumentationStageContent(props: StageProps) { + if (props.page.stage === 'source') return <SourceStage page={props.page} onContinue={props.onConfigurePhase} />; + if (!props.page.profiles || !props.page.catalog) return null; + if (props.configurePhase === 'guide' && props.page.guide) return <GuideStage {...props} />; + return <ConfigureFormStage {...props} />; +} + +function SourceStage(props: { page: PageController; onContinue: (phase: InstrumentationConfigurePhase) => void }) { + const { t } = useTranslation(); + const { page } = props; + if (!page.catalog) return null; + return ( + <div className={styles.sourceStage}> + <InstrumentationSourceStep + key={page.sourceDirectoryRevision} + catalog={page.catalog} + {...sourceSelections(page)} + onSource={page.chooseSource} + onApplicationAnswer={page.answerApplication} + /> + <Button + className={styles.sourceContinue!} + type="primary" + disabled={!page.canContinueSource} + onClick={() => { + props.onContinue('service'); + page.setStage('configure'); + }} + > + {t('instrumentation.action.continue')} + </Button> + </div> + ); +} + +function sourceSelections(page: PageController) { + return { + ...(page.draft.sourceId ? { sourceId: page.draft.sourceId } : {}), + ...(page.draft.recipeId ? { recipeId: page.draft.recipeId } : {}), + ...(page.draft.framework ? { framework: page.draft.framework } : {}), + ...(page.draft.method ? { method: page.draft.method } : {}), + ...(page.draft.environment ? { environment: page.draft.environment } : {}), + ...(page.draft.platform ? { platform: page.draft.platform } : {}) + }; +} + +function GuideStage({ page, onConfigurePhase }: StageProps) { + if (!page.catalog || !page.guide) return null; + return ( + <InstrumentationGuideWorkspace + catalog={page.catalog} + draft={page.draft} + guide={page.guide} + token={page.token} + tokenAcknowledgementRequired={page.tokenAcknowledgementRequired} + onCopy={page.copyBlock} + detecting={page.detecting} + detectionError={page.detectionError} + {...(page.detection ? { detection: page.detection } : {})} + onEdit={() => onConfigurePhase('service')} + onDetect={() => void page.detect()} + onOpen={page.openQuery} + onAcknowledgeToken={page.acknowledgeGeneratedToken} + /> + ); +} + +function ConfigureFormStage({ page, configurePhase, onConfigurePhase }: StageProps) { + if (!page.profiles) return null; + const previous = () => { + if (configurePhase === 'service') page.goBack(); + else onConfigurePhase(configurePhase === 'guide' ? 'destination' : 'service'); + }; + return ( + <InstrumentationConfigureStep + phase={configurePhase} + profiles={page.profiles} + profileId={page.draft.intakeProfileId} + service={page.draft.service} + platform={page.draft.platform} + platformOptions={page.platformOptions} + canRender={page.canRender} + rendering={page.rendering} + renderError={page.renderError} + token={page.token} + tokenDraft={page.tokenDraft} + tokenGenerating={page.tokenGenerating} + tokenError={page.tokenError} + tokenAcknowledgementRequired={page.tokenAcknowledgementRequired} + requiresToken={page.requiresToken} + canGenerateToken={page.canGenerateToken} + onProfile={intakeProfileId => page.patchDraft({ intakeProfileId })} + onService={page.patchService} + onPlatform={platform => page.patchDraft({ platform })} + onToken={page.setToken} + onRender={() => void page.renderGuide()} + onPrevious={previous} + onNext={() => onConfigurePhase(configurePhase === 'service' ? 'destination' : 'guide')} + onOpenToken={page.openTokenGenerator} + onCloseToken={page.closeTokenGenerator} + onTokenDraft={page.updateTokenDraft} + onGenerateToken={() => void page.generateToken()} + onAcknowledgeToken={page.acknowledgeGeneratedToken} + /> + ); +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
