This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 6ba9c1f5 feat(metrics): prompt for temporary credentials on protected 
data sources (#1681)
6ba9c1f5 is described below

commit 6ba9c1f5bdd115e6aeb881d03afdf58b3c801f73
Author: youngkermit8-coder <[email protected]>
AuthorDate: Tue Aug 11 20:48:51 2026 +0800

    feat(metrics): prompt for temporary credentials on protected data sources 
(#1681)
    
    Signed-off-by: youngkermit8-coder <[email protected]>
---
 web/src/api/metrics.ts                             |   2 +-
 web/src/components/MetricsExplorer.tsx             | 141 ++++++++++++++++++++-
 .../components/__tests__/MetricsExplorer.test.tsx  |  91 +++++++++++++
 3 files changed, 229 insertions(+), 5 deletions(-)

diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts
index 0b6bf8ed..211cef07 100644
--- a/web/src/api/metrics.ts
+++ b/web/src/api/metrics.ts
@@ -115,7 +115,7 @@ export interface DataSourceQuery {
 }
 
 // Runs a PromQL range query against a configured data source (key identifies 
the
-// persisted source; credentials are optional and fall back to the stored 
config).
+// persisted source; credentials are supplied per request and are never 
persisted).
 export async function queryByDataSource(params: DataSourceQuery) {
   const { key, query, instanceId, username, password, bearerToken } = params;
   const res = await client.post<{ data: MetricData }>(
diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index 2e127020..1eb990fc 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -21,6 +21,9 @@ import {
   Button,
   Empty,
   Flex,
+  Form,
+  Input,
+  Modal,
   Segmented,
   Select,
   Skeleton,
@@ -215,6 +218,25 @@ interface MetricsExplorerProps {
   instanceId?: string;
 }
 
+type DataSourceAuthMode = 'none' | 'basic' | 'bearer';
+
+interface AuthFormValues {
+  username?: string;
+  password?: string;
+  bearerToken?: string;
+}
+
+interface DataSourceCredentials extends AuthFormValues {
+  key: string;
+}
+
+const getDataSourceAuthMode = (auth: string): DataSourceAuthMode => {
+  const normalized = auth.trim().toLowerCase();
+  if (normalized === 'basic' || normalized === 'basic auth') return 'basic';
+  if (normalized === 'bearer' || normalized === 'bearer token') return 
'bearer';
+  return 'none';
+};
+
 const MetricsExplorer = ({ instanceId }: MetricsExplorerProps) => {
   const { lang } = useLang();
   const copy =
@@ -230,6 +252,14 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
           noProfiles: '暂无指标模板',
           noSamples: '暂无标量数据',
           defaultDataSource: '默认数据源',
+          authTitle: '数据源认证',
+          authDescription: '凭据仅用于当前数据源,离开该数据源后会被清除。',
+          username: '用户名',
+          password: '密码',
+          token: '令牌',
+          connect: '连接',
+          cancel: '取消',
+          required: '此项为必填项',
         }
       : {
           title: 'Prometheus Metrics',
@@ -242,7 +272,17 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
           noProfiles: 'No metric profiles',
           noSamples: 'No scalar samples',
           defaultDataSource: 'Default source',
+          authTitle: 'Data source authentication',
+          authDescription:
+            'Credentials are used only for this source and cleared when you 
leave it.',
+          username: 'Username',
+          password: 'Password',
+          token: 'Token',
+          connect: 'Connect',
+          cancel: 'Cancel',
+          required: 'This field is required',
         };
+  const [authForm] = Form.useForm<AuthFormValues>();
   const [profiles, setProfiles] = useState<MetricProfile[]>([]);
   const [profileId, setProfileId] = useState('');
   const [metricId, setMetricId] = useState('');
@@ -255,10 +295,12 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
   const [dataSources, setDataSources] = useState<DataSource[]>([]);
   const [dataSourceKey, setDataSourceKey] = useState('');
   const [dataSourcesLoading, setDataSourcesLoading] = useState(true);
+  const [pendingDataSource, setPendingDataSource] = useState<DataSource | 
null>(null);
   const requestId = useRef(0);
   // Keeps the latest data source readable from the stable loadMetrics 
callback so switching
   // the source uses the new key instead of a stale closure value.
   const dataSourceKeyRef = useRef(dataSourceKey);
+  const dataSourceCredentialsRef = useRef<DataSourceCredentials | null>(null);
 
   const selectedProfile = useMemo(
     () => profiles.find((profile) => profile.id === profileId),
@@ -293,8 +335,22 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       setQueryLoading(true);
       setQueryError(false);
       try {
-        const result = dataSourceKeyRef.current
-          ? await queryByDataSource({ key: dataSourceKeyRef.current, query, 
instanceId })
+        const currentDataSourceKey = dataSourceKeyRef.current;
+        const credentials =
+          dataSourceCredentialsRef.current?.key === currentDataSourceKey
+            ? dataSourceCredentialsRef.current
+            : null;
+        const result = currentDataSourceKey
+          ? await queryByDataSource({
+              key: currentDataSourceKey,
+              query,
+              instanceId,
+              ...(credentials?.username !== undefined ? { username: 
credentials.username } : {}),
+              ...(credentials?.password !== undefined ? { password: 
credentials.password } : {}),
+              ...(credentials?.bearerToken !== undefined
+                ? { bearerToken: credentials.bearerToken }
+                : {}),
+            })
           : await queryMetrics(query);
         if (currentRequest === requestId.current) setData(result);
       } catch {
@@ -358,13 +414,41 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
     void loadMetrics(selectedMetric, nextRange);
   };
 
-  const handleDataSourceChange = (nextKey: string) => {
+  const activateDataSource = (nextKey: string, credentials?: AuthFormValues) 
=> {
+    dataSourceCredentialsRef.current = credentials ? { key: nextKey, 
...credentials } : null;
     dataSourceKeyRef.current = nextKey;
     setDataSourceKey(nextKey);
     setData(null);
     void loadMetrics(selectedMetric, selectedRange);
   };
 
+  const handleDataSourceChange = (nextKey: string) => {
+    const nextSource = availableDataSources.find((source) => source.key === 
nextKey);
+    if (nextSource && getDataSourceAuthMode(nextSource.auth) !== 'none') {
+      authForm.resetFields();
+      setPendingDataSource(nextSource);
+      return;
+    }
+    activateDataSource(nextKey);
+  };
+
+  const handleAuthSubmit = (values: AuthFormValues) => {
+    if (!pendingDataSource) return;
+    const authMode = getDataSourceAuthMode(pendingDataSource.auth);
+    const credentials =
+      authMode === 'basic'
+        ? { username: values.username, password: values.password }
+        : { bearerToken: values.bearerToken };
+    activateDataSource(pendingDataSource.key, credentials);
+    setPendingDataSource(null);
+    authForm.resetFields();
+  };
+
+  const handleAuthCancel = () => {
+    setPendingDataSource(null);
+    authForm.resetFields();
+  };
+
   useEffect(() => {
     let cancelled = false;
     void listDataSources()
@@ -379,6 +463,7 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       });
     return () => {
       cancelled = true;
+      dataSourceCredentialsRef.current = null;
     };
   }, []);
 
@@ -391,6 +476,10 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
     }
   }, [availableDataSources, dataSourceKey]);
 
+  const pendingAuthMode = pendingDataSource
+    ? getDataSourceAuthMode(pendingDataSource.auth)
+    : 'none';
+
   return (
     <section aria-labelledby="metrics-explorer-title" style={{ marginTop: 24 
}}>
       <Flex
@@ -410,7 +499,7 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
         <Flex gap={8} wrap="wrap" align="center" style={{ maxWidth: '100%' }}>
           <Select
             aria-label="数据源"
-            value={dataSourceKey || undefined}
+            value={dataSourceKey}
             loading={dataSourcesLoading}
             onChange={handleDataSourceChange}
             options={[
@@ -496,6 +585,50 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
           />
         </>
       ) : null}
+      <Modal
+        title={copy.authTitle}
+        open={pendingDataSource !== null}
+        okText={copy.connect}
+        cancelText={copy.cancel}
+        onOk={() => authForm.submit()}
+        onCancel={handleAuthCancel}
+        afterClose={() => authForm.resetFields()}
+      >
+        <Text type="secondary">{copy.authDescription}</Text>
+        <Form<AuthFormValues>
+          form={authForm}
+          layout="vertical"
+          onFinish={handleAuthSubmit}
+          style={{ marginTop: 16 }}
+        >
+          {pendingAuthMode === 'basic' ? (
+            <>
+              <Form.Item
+                name="username"
+                label={copy.username}
+                rules={[{ required: true, whitespace: true, message: 
copy.required }]}
+              >
+                <Input autoComplete="username" />
+              </Form.Item>
+              <Form.Item
+                name="password"
+                label={copy.password}
+                rules={[{ required: true, whitespace: true, message: 
copy.required }]}
+              >
+                <Input.Password autoComplete="current-password" />
+              </Form.Item>
+            </>
+          ) : pendingAuthMode === 'bearer' ? (
+            <Form.Item
+              name="bearerToken"
+              label={copy.token}
+              rules={[{ required: true, whitespace: true, message: 
copy.required }]}
+            >
+              <Input.Password autoComplete="off" />
+            </Form.Item>
+          ) : null}
+        </Form>
+      </Modal>
     </section>
   );
 };
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index b2b466f0..d1a60ccb 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -272,6 +272,97 @@ describe('MetricsExplorer', () => {
     
expect(vi.mocked(queryMetrics).mock.calls.length).toBe(queryMetricsCallsBefore);
   });
 
+  it('prompts for basic credentials and supplies them only to the selected 
data source query', async () => {
+    const user = userEvent.setup();
+    vi.mocked(listDataSources).mockResolvedValue([
+      {
+        key: 'ds-basic',
+        name: 'Protected Prometheus',
+        type: 'Prometheus',
+        url: '',
+        auth: 'Basic Auth',
+        status: 'healthy',
+      },
+    ]);
+
+    renderWithProviders(<MetricsExplorer />);
+
+    await user.click(await screen.findByRole('combobox', { name: '数据源' }));
+    await user.click(
+      await screen.findByText('Protected Prometheus', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+
+    expect(await screen.findByRole('dialog', { name: '数据源认证' 
})).toBeInTheDocument();
+    expect(queryByDataSource).not.toHaveBeenCalled();
+
+    await user.type(screen.getByLabelText('用户名'), 'metrics-reader');
+    await user.type(screen.getByLabelText('密码'), 'secret-value');
+    await user.click(screen.getByRole('button', { name: /连\s*接/ }));
+
+    await waitFor(() =>
+      expect(queryByDataSource).toHaveBeenCalledWith({
+        key: 'ds-basic',
+        username: 'metrics-reader',
+        password: 'secret-value',
+        instanceId: undefined,
+        query: {
+          metric: 'sum(rate(rocketmq_messages_in_total[1m])) by (cluster, 
node_id)',
+          start: 1_799_996_400,
+          end: 1_800_000_000,
+          step: '30s',
+        },
+      }),
+    );
+  });
+
+  it('prompts for a bearer token without persisting it when the source is 
left', async () => {
+    const user = userEvent.setup();
+    vi.mocked(listDataSources).mockResolvedValue([
+      {
+        key: 'ds-bearer',
+        name: 'Bearer Prometheus',
+        type: 'Prometheus',
+        url: '',
+        auth: 'Bearer Token',
+        status: 'healthy',
+      },
+    ]);
+
+    renderWithProviders(<MetricsExplorer />);
+
+    const sourceSelect = await screen.findByRole('combobox', { name: '数据源' });
+    await user.click(sourceSelect);
+    await user.click(
+      await screen.findByText('Bearer Prometheus', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+    await user.type(await screen.findByLabelText('令牌'), 'ephemeral-token');
+    await user.click(screen.getByRole('button', { name: /连\s*接/ }));
+
+    await waitFor(() =>
+      expect(queryByDataSource).toHaveBeenCalledWith(
+        expect.objectContaining({ key: 'ds-bearer', bearerToken: 
'ephemeral-token' }),
+      ),
+    );
+
+    await user.click(sourceSelect);
+    await user.click(
+      await screen.findByText('默认数据源', { selector: 
'.ant-select-item-option-content' }),
+    );
+    await user.click(sourceSelect);
+    await user.click(
+      await screen.findByText('Bearer Prometheus', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+
+    expect(await screen.findByRole('dialog', { name: '数据源认证' 
})).toBeInTheDocument();
+    expect(screen.getByLabelText('令牌')).toHaveValue('');
+  });
+
   it('only offers data sources bound to the selected instance or globally 
available', async () => {
     vi.mocked(listDataSources).mockResolvedValue([
       {

Reply via email to