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([
{