This is an automated email from the ASF dual-hosted git repository.
liuhongyu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new f5a54b67 feat:ai proxy apikey (#548)
f5a54b67 is described below
commit f5a54b67ed953a74afb1a5ab5b9f6c4f1bdb55ac
Author: LingXiao Qi <[email protected]>
AuthorDate: Thu Sep 25 18:13:37 2025 +0800
feat:ai proxy apikey (#548)
* feat:ai proxy apikey
* add use info
* fix comment and clear selector state when delete
---
src/common/router.js | 7 +
src/locales/en-US.json | 12 +-
src/locales/zh-CN.json | 12 +-
src/models/pluginHandle.js | 38 +-
src/routes/Plugin/AiProxy/ApiKeys/index.js | 570 +++++++++++++++++++++++++++++
src/routes/Plugin/Common/index.js | 388 ++++++++++++--------
src/services/api.js | 66 ++++
7 files changed, 934 insertions(+), 159 deletions(-)
diff --git a/src/common/router.js b/src/common/router.js
index 66b454e7..fefb2499 100644
--- a/src/common/router.js
+++ b/src/common/router.js
@@ -119,6 +119,13 @@ export const getRouterData = (app) => {
() => import("../routes/Plugin/Common"),
),
},
+ "/plugin/ai-proxy/apikeys": {
+ component: dynamicWrapper(
+ app,
+ [],
+ () => import("../routes/Plugin/AiProxy/ApiKeys"),
+ ),
+ },
"/system/role": {
component: dynamicWrapper(
app,
diff --git a/src/locales/en-US.json b/src/locales/en-US.json
index 8ccf3578..9d17af2a 100644
--- a/src/locales/en-US.json
+++ b/src/locales/en-US.json
@@ -635,5 +635,15 @@
"SHENYU.MCP.CONFIG.EXPLANATION.HEADERS": "HTTP headers required for
requests. X-Client-ID and Authorization should be filled according to actual
situation",
"SHENYU.MCP.CONFIG.EXPLANATION.TRANSPORT": "Transport protocol type",
"SHENYU.MCP.CONFIG.JSON.TITLE": "JSON Configuration (Copy Ready):",
- "SHENYU.MCP.CONFIG.COPY.JSON": "Copy JSON"
+ "SHENYU.MCP.CONFIG.COPY.JSON": "Copy JSON",
+ "APIPROXY.APIKEY.MANAGE": "API Key Manage",
+ "APIPROXY.APIKEY.CREATE": "Create API Key",
+ "APIPROXY.APIKEY.EDIT": "Edit API Key",
+ "APIPROXY.APIKEY.ONLY_ONE_SELECTOR": "Please select only one selector",
+ "APIPROXY.APIKEY.USAGE.TITLE": "Usage",
+ "APIPROXY.APIKEY.USAGE.DESC": "Proxy API Key is bound at selector level.
When using, add header X-API-KEY: <proxyApiKey>. This check has the highest
priority. The realApiKey is strongly bound to the selector configuration. To
change the mapping between proxyApiKey and realApiKey, just modify the selector
configuration.",
+ "APIPROXY.APIKEY.USAGE.LINE1": "Proxy API Key works at selector granularity.
Add header X-API-KEY: <proxyApiKey>; this check has the highest priority.",
+ "APIPROXY.APIKEY.USAGE.LINE2": "The realApiKey is strongly bound to the
selector configuration. To change the mapping between proxyApiKey and
realApiKey, edit the selector configuration.",
+ "APIPROXY.APIKEY.USAGE.HINT": "How to use: Add this API Key to the X-API-KEY
field in your request header.",
+ "APIPROXY.APIKEY.PROXY_ENABLED_REQUIRED": "Please set proxyEnabled to
\"true\" in this selector's configuration before entering Proxy API Key
Management."
}
diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json
index 221ff74c..c6f3510a 100644
--- a/src/locales/zh-CN.json
+++ b/src/locales/zh-CN.json
@@ -641,5 +641,15 @@
"SHENYU.MCP.CONFIG.EXPLANATION.HEADERS":
"请求时需要携带的HTTP头信息。X-Client-ID和Authorization需要根据实际情况填写",
"SHENYU.MCP.CONFIG.EXPLANATION.TRANSPORT": "传输协议类型",
"SHENYU.MCP.CONFIG.JSON.TITLE": "JSON配置(可直接复制):",
- "SHENYU.MCP.CONFIG.COPY.JSON": "复制JSON"
+ "SHENYU.MCP.CONFIG.COPY.JSON": "复制JSON",
+ "APIPROXY.APIKEY.MANAGE": "API Key 管理",
+ "APIPROXY.APIKEY.CREATE": "新增 API Key",
+ "APIPROXY.APIKEY.EDIT": "编辑 API Key",
+ "APIPROXY.APIKEY.ONLY_ONE_SELECTOR": "只能选择一个选择器",
+ "APIPROXY.APIKEY.USAGE.TITLE": "使用提示",
+ "APIPROXY.APIKEY.USAGE.DESC": "代理 API Key 绑定在选择器(Selector)维度。调用时在请求头添加
X-API-KEY: <proxyApiKey>,该校验优先级最高。真实 API Key 强绑定于选择器(Selector)配置,如需改变代理 API Key
与真实 API Key 的映射,只需修改选择器配置即可。",
+ "APIPROXY.APIKEY.USAGE.LINE1": "代理 API Key 以选择器(Selector)为粒度生效,请在请求头添加
X-API-KEY: <proxyApiKey>,其校验优先级最高。",
+ "APIPROXY.APIKEY.USAGE.LINE2": "真实 API Key
与选择器配置强绑定,如需变更代理与真实的映射,请修改该选择器的配置。",
+ "APIPROXY.APIKEY.USAGE.HINT": "使用提示:请将此 API Key 添加到请求头的 X-API-KEY 字段。",
+ "APIPROXY.APIKEY.PROXY_ENABLED_REQUIRED": "请先在该选择器的配置中将 proxyEnabled 设置为
\"true\",再进入代理 API Key 管理。"
}
diff --git a/src/models/pluginHandle.js b/src/models/pluginHandle.js
index d587a9f5..9a912bb6 100644
--- a/src/models/pluginHandle.js
+++ b/src/models/pluginHandle.js
@@ -114,7 +114,13 @@ export default {
typeof handle !== "undefined" &&
handle.indexOf("{") !== -1
) {
- handleJson = JSON.parse(handle);
+ try {
+ handleJson = JSON.parse(handle);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to parse plugin handle JSON:", handle, e);
+ handleJson = undefined;
+ }
}
const json = yield call(fetchPluginHandleByPluginId, payload);
if (json.code === 200) {
@@ -148,17 +154,29 @@ export default {
typeof item.extObj !== "undefined" &&
item.extObj.indexOf("{") !== -1
) {
- let extObj = JSON.parse(item.extObj);
- item.required = extObj.required;
- if (extObj.defaultValue || extObj.defaultValue === 0) {
- if (item.dataType === 1) {
- item.defaultValue = Number(extObj.defaultValue);
- } else {
- item.defaultValue = extObj.defaultValue;
+ try {
+ let extObj = JSON.parse(item.extObj);
+ item.required = extObj && extObj.required;
+ if (
+ extObj &&
+ (extObj.defaultValue || extObj.defaultValue === 0)
+ ) {
+ if (item.dataType === 1) {
+ item.defaultValue = Number(extObj.defaultValue);
+ } else {
+ item.defaultValue = extObj.defaultValue;
+ }
}
+ item.checkRule = extObj && extObj.rule;
+ item.placeholder = extObj && extObj.placeholder;
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(
+ "Failed to parse extObj JSON:",
+ item && item.extObj,
+ e,
+ );
}
- item.checkRule = extObj.rule;
- item.placeholder = extObj.placeholder;
}
return item;
});
diff --git a/src/routes/Plugin/AiProxy/ApiKeys/index.js
b/src/routes/Plugin/AiProxy/ApiKeys/index.js
new file mode 100644
index 00000000..d8812cca
--- /dev/null
+++ b/src/routes/Plugin/AiProxy/ApiKeys/index.js
@@ -0,0 +1,570 @@
+/*
+ * 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
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { useEffect, useMemo, useState } from "react";
+import { connect } from "dva";
+import {
+ Button,
+ Input,
+ Row,
+ Col,
+ Table,
+ Switch,
+ Modal,
+ Form,
+ Typography,
+ Popconfirm,
+ message,
+ Alert,
+} from "antd";
+import styles from "../../index.less";
+import { getIntlContent } from "../../../../utils/IntlUtils";
+import AuthButton from "../../../../utils/AuthButton";
+import {
+ addAiProxyApiKey,
+ getAiProxyApiKeys,
+ updateAiProxyApiKey,
+ batchDeleteAiProxyApiKeys,
+ batchEnableAiProxyApiKeys,
+} from "../../../../services/api";
+
+const { Search } = Input;
+const { Title } = Typography;
+
+// proxyApiKey needs to be fully visible on the management page, so we do not
mask it
+class CreateModalInner extends React.Component {
+ componentDidUpdate(prevProps) {
+ const { visible, defaultNamespace, form } = this.props;
+ if (visible && visible !== prevProps.visible) {
+ form.resetFields();
+ form.setFieldsValue({
+ enabled: true,
+ namespaceId: defaultNamespace,
+ });
+ }
+ }
+
+ handleOk = () => {
+ const { form, onOk } = this.props;
+ form.validateFieldsAndScroll((err, values) => {
+ if (!err) {
+ onOk({ ...values });
+ }
+ });
+ };
+
+ render() {
+ const { visible, onCancel, defaultNamespace, form, realApiKeyPreset } =
+ this.props;
+ const { getFieldDecorator } = form;
+ return (
+ <Modal
+ title={getIntlContent("APIPROXY.APIKEY.CREATE") || "Create API Key"}
+ visible={visible}
+ onCancel={onCancel}
+ onOk={this.handleOk}
+ okText={getIntlContent("SHENYU.COMMON.SAVE")}
+ cancelText={getIntlContent("SHENYU.COMMON.CALCEL")}
+ destroyOnClose
+ >
+ <Form layout="vertical">
+ <Form.Item label="Real API Key">
+ <Input value={realApiKeyPreset} disabled />
+ </Form.Item>
+ <Form.Item
+ label={getIntlContent("SHENYU.SYSTEM.DESCRIBE") || "Description"}
+ >
+ {getFieldDecorator("description")(<Input maxLength={200} />)}
+ </Form.Item>
+ <Form.Item label={getIntlContent("SHENYU.COMMON.OPEN") || "Enabled"}>
+ {getFieldDecorator("enabled", {
+ valuePropName: "checked",
+ initialValue: true,
+ })(<Switch />)}
+ </Form.Item>
+ <Form.Item label="Namespace">
+ {getFieldDecorator("namespaceId", {
+ initialValue: defaultNamespace,
+ })(<Input disabled placeholder="default" />)}
+ </Form.Item>
+ </Form>
+ </Modal>
+ );
+ }
+}
+const CreateModal = Form.create()(CreateModalInner);
+
+function EditModalStateless(props) {
+ const { visible, onCancel, onOk, record, defaultNamespace, form } = props;
+ const { getFieldDecorator, resetFields, setFieldsValue } = form;
+ React.useEffect(() => {
+ if (visible) {
+ resetFields();
+ setFieldsValue({
+ description: record && record.description,
+ enabled: record && record.enabled,
+ namespaceId: defaultNamespace,
+ });
+ }
+ }, [visible, record, defaultNamespace]);
+
+ const handleOk = () => {
+ form.validateFieldsAndScroll((err, values) => {
+ if (!err) onOk(values);
+ });
+ };
+
+ return (
+ <Modal
+ title={getIntlContent("APIPROXY.APIKEY.EDIT") || "Edit API Key"}
+ visible={visible}
+ onCancel={onCancel}
+ onOk={handleOk}
+ okText={getIntlContent("SHENYU.COMMON.SAVE")}
+ cancelText={getIntlContent("SHENYU.COMMON.CALCEL")}
+ destroyOnClose
+ >
+ <Form layout="vertical">
+ <Form.Item label="Proxy API Key">
+ <Input value={record && record.proxyApiKey} disabled />
+ </Form.Item>
+ <Form.Item
+ label={getIntlContent("SHENYU.SYSTEM.DESCRIBE") || "Description"}
+ >
+ {getFieldDecorator("description", {
+ initialValue: record && record.description,
+ })(<Input maxLength={200} />)}
+ </Form.Item>
+ <Form.Item label={getIntlContent("SHENYU.COMMON.OPEN") || "Enabled"}>
+ {getFieldDecorator("enabled", {
+ valuePropName: "checked",
+ initialValue: record ? record.enabled : true,
+ })(<Switch />)}
+ </Form.Item>
+ <Form.Item label="Namespace">
+ {getFieldDecorator("namespaceId", {
+ initialValue: defaultNamespace,
+ })(<Input disabled placeholder="default" />)}
+ </Form.Item>
+ </Form>
+ </Modal>
+ );
+}
+const EditModal = Form.create()(EditModalStateless);
+
+function ApiKeysPage({
+ currentNamespaceId,
+ initialSelectorId,
+ initialNamespaceId,
+ onBack,
+ currentSelector,
+}) {
+ const [selectorId, setSelectorId] = useState(initialSelectorId);
+ const [namespaceId, setNamespaceId] = useState(
+ initialNamespaceId || currentNamespaceId || "default",
+ );
+ const [queryKey, setQueryKey] = useState();
+ const [data, setData] = useState([]);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
+ const [total, setTotal] = useState(0);
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editOpen, setEditOpen] = useState(false);
+ const [current, setCurrent] = useState();
+ const realKeyFromSelector = useMemo(() => {
+ try {
+ const handle = currentSelector && currentSelector.handle;
+ if (handle) {
+ const obj = typeof handle === "string" ? JSON.parse(handle) : handle;
+ return obj && (obj.realApiKey || obj.realKey || obj.apiKey);
+ }
+ } catch (e) {
+ // ignore
+ }
+ return undefined;
+ }, [currentSelector]);
+
+ useEffect(() => {
+ if (initialSelectorId) setSelectorId(initialSelectorId);
+ if (initialNamespaceId) setNamespaceId(initialNamespaceId);
+ // eslint-disable-next-line
+ }, [initialSelectorId, initialNamespaceId]);
+
+ useEffect(() => {
+ if (selectorId) {
+ fetchList(1, pageSize);
+ }
+ // eslint-disable-next-line
+ }, [selectorId, namespaceId]);
+
+ const columns = useMemo(
+ () => [
+ { title: "proxyApiKey", dataIndex: "proxyApiKey", key: "proxyApiKey" },
+ {
+ title: getIntlContent("SHENYU.SYSTEM.DESCRIBE") || "Description",
+ dataIndex: "description",
+ key: "description",
+ },
+ {
+ title: getIntlContent("SHENYU.COMMON.OPEN") || "Enabled",
+ dataIndex: "enabled",
+ key: "enabled",
+ render: (v, r) => (
+ <Switch
+ checked={v}
+ onChange={(checked) => onToggle([r.id], checked)}
+ />
+ ),
+ },
+ {
+ title: getIntlContent("SHENYU.SYSTEM.UPDATETIME") || "Update Time",
+ dataIndex: "dateUpdated",
+ key: "dateUpdated",
+ },
+ {
+ title: getIntlContent("SHENYU.COMMON.OPERAT") || "Operation",
+ key: "op",
+ render: (_, r) => (
+ <div>
+ <AuthButton perms="system:aiProxyApiKey:edit">
+ <span
+ className="edit"
+ style={{ marginRight: 8 }}
+ onClick={() => {
+ setCurrent(r);
+ setEditOpen(true);
+ }}
+ >
+ {getIntlContent("SHENYU.COMMON.CHANGE")}
+ </span>
+ </AuthButton>
+ <AuthButton perms="system:aiProxyApiKey:delete">
+ <Popconfirm
+ title={
+ getIntlContent("SHENYU.COMMON.DELETE") || "Confirm delete?"
+ }
+ onConfirm={() => onBatchDelete([r.id])}
+ okText={getIntlContent("SHENYU.COMMON.SURE")}
+ cancelText={getIntlContent("SHENYU.COMMON.CALCEL")}
+ >
+ <span className="edit">
+ {getIntlContent("SHENYU.COMMON.DELETE.NAME")}
+ </span>
+ </Popconfirm>
+ </AuthButton>
+ </div>
+ ),
+ },
+ ],
+ [namespaceId, onToggle],
+ );
+
+ const fetchList = async (p = page, ps = pageSize) => {
+ if (!selectorId) return;
+ const res = await getAiProxyApiKeys({
+ selectorId,
+ namespaceId,
+ currentPage: p,
+ pageSize: ps,
+ proxyApiKey:
+ queryKey && queryKey.trim() !== "" ? queryKey.trim() : undefined,
+ });
+ const list = res?.data?.dataList || [];
+ setData(list);
+ setTotal(res?.data?.totalCount || 0);
+ };
+
+ const onToggle = async (ids, enabled) => {
+ if (!selectorId) return;
+ await batchEnableAiProxyApiKeys({ selectorId, ids, enabled });
+ fetchList();
+ };
+
+ const onBatchDelete = async (ids) => {
+ if (!selectorId) return;
+ await batchDeleteAiProxyApiKeys({ selectorId, ids });
+ fetchList();
+ };
+
+ const onCreate = async (values) => {
+ if (!selectorId) return;
+ const res = await addAiProxyApiKey({ selectorId, ...values });
+ setCreateOpen(false);
+ const newKey = res && res.data && res.data.proxyApiKey;
+ if (newKey) {
+ const copy = async (text) => {
+ try {
+ if (
+ navigator &&
+ navigator.clipboard &&
+ navigator.clipboard.writeText
+ ) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ document.body.removeChild(ta);
+ }
+ message.success(getIntlContent("SHENYU.COMMON.COPY") || "Copy");
+ } catch (e) {
+ message.warn("Copy failed");
+ }
+ };
+ Modal.success({
+ title: getIntlContent("APIPROXY.APIKEY.CREATE") || "Create API Key",
+ content: (
+ <div>
+ <div style={{ marginBottom: 6 }}>Proxy API Key:</div>
+ <div
+ style={{
+ wordBreak: "break-all",
+ fontWeight: 600,
+ marginBottom: 8,
+ }}
+ >
+ {newKey}
+ </div>
+ <Button onClick={() => copy(newKey)}>
+ {getIntlContent("SHENYU.COMMON.COPY") || "Copy"}
+ </Button>
+ <div style={{ marginTop: 16, color: "rgba(0, 0, 0, 0.45)" }}>
+ {getIntlContent("APIPROXY.APIKEY.USAGE.HINT")}
+ </div>
+ </div>
+ ),
+ });
+ } else {
+ message.success(
+ getIntlContent("SHENYU.COMMON.RESPONSE.ADD.SUCCESS") ||
+ "Create success",
+ );
+ }
+ fetchList(1, pageSize);
+ };
+
+ const onEdit = async (values) => {
+ if (!selectorId || !current) return;
+ await updateAiProxyApiKey({ selectorId, id: current.id, ...values });
+ setEditOpen(false);
+ fetchList(page, pageSize);
+ };
+
+ const onBatchDeleteConfirm = () => {
+ if (!selectedRowKeys || selectedRowKeys.length === 0) {
+ message.destroy();
+ message.warn(
+ getIntlContent("SHENYU.COMMON.WARN.INPUT_SELECTOR") ||
+ "Please select data",
+ );
+ return;
+ }
+ Modal.confirm({
+ title: getIntlContent("SHENYU.COMMON.DELETE") || "Confirm delete?",
+ okText: getIntlContent("SHENYU.COMMON.SURE"),
+ cancelText: getIntlContent("SHENYU.COMMON.CALCEL"),
+ onOk: () => onBatchDelete(selectedRowKeys),
+ });
+ };
+
+ const onBatchEnable = () => {
+ if (!selectedRowKeys || selectedRowKeys.length === 0) {
+ message.destroy();
+ message.warn(
+ getIntlContent("SHENYU.COMMON.WARN.INPUT_SELECTOR") ||
+ "Please select data",
+ );
+ return;
+ }
+ onToggle(selectedRowKeys, true);
+ };
+
+ const onBatchDisable = () => {
+ if (!selectedRowKeys || selectedRowKeys.length === 0) {
+ message.destroy();
+ message.warn(
+ getIntlContent("SHENYU.COMMON.WARN.INPUT_SELECTOR") ||
+ "Please select data",
+ );
+ return;
+ }
+ onToggle(selectedRowKeys, false);
+ };
+
+ return (
+ <div className="plug-content-wrap">
+ <Row
+ style={{
+ marginBottom: 5,
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ }}
+ >
+ <div style={{ display: "flex", alignItems: "end", flex: 1, margin: 0
}}>
+ {onBack ? (
+ <Button style={{ marginRight: 12 }} onClick={() => onBack()}>
+ {getIntlContent("SHENYU.COMMON.BACK") || "Back"}
+ </Button>
+ ) : null}
+ <Title
+ level={2}
+ style={{ textTransform: "capitalize", margin: "0 20px 0 0" }}
+ >
+ API Key
+ </Title>
+ <Title level={3} type="secondary" style={{ margin: "0 20px 0 0" }}>
+ Ai
+ </Title>
+ </div>
+ </Row>
+
+ {selectorId ? (
+ <Alert
+ type="info"
+ showIcon
+ message={getIntlContent("APIPROXY.APIKEY.USAGE.TITLE")}
+ description={
+ <div>
+ <div>{getIntlContent("APIPROXY.APIKEY.USAGE.LINE1")}</div>
+ <div style={{ marginTop: 4 }}>
+ {getIntlContent("APIPROXY.APIKEY.USAGE.LINE2")}
+ </div>
+ </div>
+ }
+ style={{ marginBottom: 12 }}
+ />
+ ) : null}
+
+ <Row gutter={20}>
+ <Col span={24}>
+ <div className="table-header">
+ <div className={styles.headerSearch}>
+ <Search
+ className={styles.search}
+ style={{ minWidth: 160 }}
+ placeholder="proxyApiKey"
+ onSearch={(v) => {
+ const value = (v || "").trim();
+ setQueryKey(value === "" ? undefined : value);
+ setPage(1);
+ fetchList(1, pageSize);
+ }}
+ />
+ <Button
+ style={{ marginLeft: 10 }}
+ onClick={() => {
+ setQueryKey(undefined);
+ setPage(1);
+ fetchList(1, pageSize);
+ }}
+ >
+ {getIntlContent("SHENYU.COMMON.REFRESH") || "Refresh"}
+ </Button>
+ <AuthButton perms="system:aiProxyApiKey:add">
+ <Button
+ type="primary"
+ onClick={() => setCreateOpen(true)}
+ style={{ marginLeft: 10 }}
+ >
+ {getIntlContent("SHENYU.COMMON.ADD")}
+ </Button>
+ </AuthButton>
+ <AuthButton perms="system:aiProxyApiKey:disable">
+ <Button
+ type="primary"
+ onClick={onBatchEnable}
+ style={{ marginLeft: 10 }}
+ >
+ {getIntlContent("SHENYU.PLUGIN.SELECTOR.BATCH.OPENED")}
+ </Button>
+ </AuthButton>
+ <AuthButton perms="system:aiProxyApiKey:disable">
+ <Button
+ type="primary"
+ onClick={onBatchDisable}
+ style={{ marginLeft: 10 }}
+ >
+ {getIntlContent("SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED")}
+ </Button>
+ </AuthButton>
+ <AuthButton perms="system:aiProxyApiKey:delete">
+ <Button
+ type="primary"
+ onClick={onBatchDeleteConfirm}
+ style={{ marginLeft: 10 }}
+ >
+ {getIntlContent("SHENYU.SYSTEM.DELETEDATA")}
+ </Button>
+ </AuthButton>
+ </div>
+ </div>
+
+ <Table
+ size="small"
+ style={{ marginTop: 20 }}
+ rowKey={(r) => r.id}
+ bordered
+ columns={columns}
+ dataSource={data}
+ rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
+ pagination={{
+ total,
+ showTotal: (t) => `${t}`,
+ showSizeChanger: true,
+ pageSizeOptions: ["10", "20", "50", "100"],
+ current: page,
+ pageSize,
+ onChange: (p, ps) => {
+ setPage(p);
+ setPageSize(ps);
+ fetchList(p, ps);
+ },
+ onShowSizeChange: (cp, ps) => {
+ setPage(1);
+ setPageSize(ps);
+ fetchList(1, ps);
+ },
+ }}
+ />
+ </Col>
+ </Row>
+
+ <CreateModal
+ visible={createOpen}
+ onCancel={() => setCreateOpen(false)}
+ onOk={onCreate}
+ defaultNamespace={namespaceId}
+ realApiKeyPreset={realKeyFromSelector}
+ />
+ <EditModal
+ visible={editOpen}
+ onCancel={() => setEditOpen(false)}
+ onOk={onEdit}
+ record={current}
+ defaultNamespace={namespaceId}
+ />
+ </div>
+ );
+}
+
+export default connect(({ global }) => ({
+ currentNamespaceId: global.currentNamespaceId,
+}))(ApiKeysPage);
diff --git a/src/routes/Plugin/Common/index.js
b/src/routes/Plugin/Common/index.js
index b61e01f3..9f5601de 100755
--- a/src/routes/Plugin/Common/index.js
+++ b/src/routes/Plugin/Common/index.js
@@ -38,6 +38,7 @@ import {
getUpdateModal,
updateNamespacePluginsEnabledByNamespace,
} from "../../../utils/namespacePlugin";
+import ApiKeysPage from "../AiProxy/ApiKeys";
const { Search } = Input;
const { Title } = Typography;
@@ -61,6 +62,7 @@ export default class Common extends Component {
selectorName: undefined,
ruleName: undefined,
isPluginEnabled: false,
+ showApiKeyManage: false,
};
}
@@ -715,6 +717,13 @@ export default class Common extends Component {
deleteSelector = (record) => {
const { dispatch, plugins, currentNamespaceId } = this.props;
const { selectorPage, selectorPageSize } = this.state;
+ // Clear local selected state if the deleted selector is currently
selected to avoid stale selection after deletion
+ if (
+ Array.isArray(this.state.selectorSelectedRowKeys) &&
+ this.state.selectorSelectedRowKeys.includes(record.id)
+ ) {
+ this.setState({ selectorSelectedRowKeys: [] });
+ }
let name = this.props.match.params ? this.props.match.params.id : "";
const pluginId = this.getPluginId(plugins, name);
dispatch({
@@ -763,9 +772,7 @@ export default class Common extends Component {
const { selectorPageSize } = this.state;
dispatch({
type: "common/saveCurrentSelector",
- payload: {
- currentSelector: record,
- },
+ payload: { currentSelector: record },
});
dispatch({
type: "common/fetchRule",
@@ -1170,154 +1177,241 @@ export default class Common extends Component {
</AuthButton>
</div>
</Row>
- <Row gutter={20}>
- <Col span={10}>
- <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.TITLE")}</h3>
- <div className="table-header">
- <div className={styles.headerSearch}>
- <AuthButton perms={`plugin:${name}Selector:query`}>
- <Search
- className={styles.search}
- style={{ minWidth: "130px" }}
- placeholder={getIntlContent(
- "SHENYU.PLUGIN.SEARCH.SELECTOR.NAME",
- )}
- enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
- size="default"
- onChange={this.searchSelectorOnchange}
- onSearch={this.searchSelector}
- />
- </AuthButton>
- <AuthButton perms={`plugin:${name}Selector:add`}>
- <Button type="primary" onClick={this.addSelector}>
- {getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.ADD")}
- </Button>
- </AuthButton>
- <AuthButton perms={`plugin:${name}Selector:edit`}>
- <Button
- type="primary"
- onClick={this.openSelectorClick}
- style={{ marginLeft: 10 }}
- >
- {getIntlContent(
- selectorList.some(
- (selector) =>
- selectorSelectedRowKeys.includes(selector.id) &&
- selector.enabled,
- )
- ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
- : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
- )}
- </Button>
- </AuthButton>
+ {name === "aiProxy" && this.state.showApiKeyManage ? (
+ (() => {
+ const selectedId =
+ this.state.selectorSelectedRowKeys &&
+ this.state.selectorSelectedRowKeys.length === 1
+ ? this.state.selectorSelectedRowKeys[0]
+ : this.props.currentSelector?.id;
+ const selectedSelector =
+ (this.props.selectorList || []).find(
+ (s) => s.id === selectedId,
+ ) || this.props.currentSelector;
+ return (
+ <ApiKeysPage
+ initialSelectorId={selectedId}
+ initialNamespaceId={this.props.currentNamespaceId}
+ pluginId={this.state.pluginId}
+ currentSelector={selectedSelector}
+ onBack={() => this.setState({ showApiKeyManage: false })}
+ />
+ );
+ })()
+ ) : (
+ <Row gutter={20}>
+ <Col span={10}>
+ <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.TITLE")}</h3>
+ <div className="table-header">
+ <div className={styles.headerSearch}>
+ <AuthButton perms={`plugin:${name}Selector:query`}>
+ <Search
+ className={styles.search}
+ style={{ minWidth: "130px" }}
+ placeholder={getIntlContent(
+ "SHENYU.PLUGIN.SEARCH.SELECTOR.NAME",
+ )}
+ enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
+ size="default"
+ onChange={this.searchSelectorOnchange}
+ onSearch={this.searchSelector}
+ />
+ </AuthButton>
+ <AuthButton perms={`plugin:${name}Selector:add`}>
+ <Button type="primary" onClick={this.addSelector}>
+ {getIntlContent("SHENYU.PLUGIN.SELECTOR.LIST.ADD")}
+ </Button>
+ </AuthButton>
+ <AuthButton perms={`plugin:${name}Selector:edit`}>
+ <Button
+ type="primary"
+ onClick={this.openSelectorClick}
+ style={{ marginLeft: 10 }}
+ >
+ {getIntlContent(
+ selectorList.some(
+ (selector) =>
+ selectorSelectedRowKeys.includes(selector.id) &&
+ selector.enabled,
+ )
+ ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
+ : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
+ )}
+ </Button>
+ </AuthButton>
+ {name === "aiProxy" ? (
+ <AuthButton perms="system:aiProxyApiKey:list">
+ <Button
+ type="primary"
+ onClick={() => {
+ const keys = this.state.selectorSelectedRowKeys;
+ if (!keys || keys.length === 0) {
+ message.destroy();
+ message.warn(
+ getIntlContent(
+ "SHENYU.COMMON.WARN.INPUT_SELECTOR",
+ ),
+ );
+ return;
+ }
+ if (keys.length > 1) {
+ message.destroy();
+ message.warn(
+ getIntlContent(
+ "APIPROXY.APIKEY.ONLY_ONE_SELECTOR",
+ ),
+ );
+ return;
+ }
+ const selectedId = keys[0];
+ const selected =
+ (this.props.selectorList || []).find(
+ (s) => s.id === selectedId,
+ ) || this.props.currentSelector;
+ const readProxyEnabled = () => {
+ const handle = selected && selected.handle;
+ if (!handle) return undefined;
+ if (typeof handle === "string") {
+ try {
+ const obj = JSON.parse(handle);
+ return obj && obj.proxyEnabled;
+ } catch (err) {
+ return undefined;
+ }
+ }
+ return handle.proxyEnabled;
+ };
+ const proxyEnabled = readProxyEnabled();
+ if (`${proxyEnabled}` !== "true") {
+ message.destroy();
+ message.warn(
+ getIntlContent(
+ "APIPROXY.APIKEY.PROXY_ENABLED_REQUIRED",
+ ),
+ );
+ return;
+ }
+ this.setState({ showApiKeyManage: true });
+ }}
+ style={{ marginLeft: 10 }}
+ >
+ {getIntlContent("APIPROXY.APIKEY.MANAGE") ||
+ "Proxy API Key Manage"}
+ </Button>
+ </AuthButton>
+ ) : null}
+ </div>
</div>
- </div>
- <Table
- size="small"
- onRow={(record) => {
- return {
- onClick: () => {
- this.rowClick(record);
- },
- };
- }}
- style={{ marginTop: 30 }}
- bordered
- columns={selectColumns}
- dataSource={selectorList}
- rowSelection={selectorRowSelection}
- pagination={{
- total: selectorTotal,
- showTotal: (showTotal) => `${showTotal}`,
- showSizeChanger: true,
- pageSizeOptions: ["12", "20", "50", "100"],
- current: selectorPage,
- pageSize: selectorPageSize,
- onChange: this.pageSelectorChange,
- onShowSizeChange: this.pageSelectorChangeSize,
- }}
- rowClassName={(item) => {
- if (currentSelector && currentSelector.id === item.id) {
- return "table-selected";
- } else {
- return "";
- }
- }}
- />
- </Col>
- <Col span={14}>
- <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.RULE.LIST")}</h3>
+ <Table
+ size="small"
+ onRow={(record) => {
+ return {
+ onClick: () => {
+ this.rowClick(record);
+ },
+ };
+ }}
+ style={{ marginTop: 30 }}
+ bordered
+ columns={selectColumns}
+ dataSource={selectorList}
+ rowSelection={selectorRowSelection}
+ pagination={{
+ total: selectorTotal,
+ showTotal: (showTotal) => `${showTotal}`,
+ showSizeChanger: true,
+ pageSizeOptions: ["12", "20", "50", "100"],
+ current: selectorPage,
+ pageSize: selectorPageSize,
+ onChange: this.pageSelectorChange,
+ onShowSizeChange: this.pageSelectorChangeSize,
+ }}
+ rowClassName={(item) => {
+ if (currentSelector && currentSelector.id === item.id) {
+ return "table-selected";
+ } else {
+ return "";
+ }
+ }}
+ />
+ </Col>
+ <Col span={14}>
+ <h3>{getIntlContent("SHENYU.PLUGIN.SELECTOR.RULE.LIST")}</h3>
- <div className="table-header">
- <div style={{ display: "flex", alignItems: "center" }}>
- <AuthButton perms={`plugin:${name}:modify`}>
- <Button
- icon="reload"
- onClick={this.asyncClick}
- type="primary"
- >
- {getIntlContent("SHENYU.COMMON.SYN")} {name}
- </Button>
- </AuthButton>
- </div>
+ <div className="table-header">
+ <div style={{ display: "flex", alignItems: "center" }}>
+ <AuthButton perms={`plugin:${name}:modify`}>
+ <Button
+ icon="reload"
+ onClick={this.asyncClick}
+ type="primary"
+ >
+ {getIntlContent("SHENYU.COMMON.SYN")} {name}
+ </Button>
+ </AuthButton>
+ </div>
- <div className={`${styles.headerSearch} ${styles.marginLeft10}`}>
- <AuthButton perms={`plugin:${name}Rule:query`}>
- <Search
- className={styles.search}
- placeholder={getIntlContent(
- "SHENYU.PLUGIN.SEARCH.RULE.NAME",
- )}
- enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
- size="default"
- onChange={this.searchRuleOnchange}
- onSearch={this.searchRule}
- />
- </AuthButton>
- <AuthButton perms={`plugin:${name}Rule:add`}>
- <Button type="primary" onClick={this.addRule}>
- {getIntlContent("SHENYU.COMMON.ADD.RULE")}
- </Button>
- </AuthButton>
- <AuthButton perms={`plugin:${name}Rule:edit`}>
- <Button
- type="primary"
- onClick={this.openRuleClick}
- style={{ marginLeft: 10 }}
- >
- {getIntlContent(
- ruleList.some(
- (rule) =>
- ruleSelectedRowKeys.includes(rule.id) &&
rule.enabled,
- )
- ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
- : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
- )}
- </Button>
- </AuthButton>
+ <div
+ className={`${styles.headerSearch} ${styles.marginLeft10}`}
+ >
+ <AuthButton perms={`plugin:${name}Rule:query`}>
+ <Search
+ className={styles.search}
+ placeholder={getIntlContent(
+ "SHENYU.PLUGIN.SEARCH.RULE.NAME",
+ )}
+ enterButton={getIntlContent("SHENYU.SYSTEM.SEARCH")}
+ size="default"
+ onChange={this.searchRuleOnchange}
+ onSearch={this.searchRule}
+ />
+ </AuthButton>
+ <AuthButton perms={`plugin:${name}Rule:add`}>
+ <Button type="primary" onClick={this.addRule}>
+ {getIntlContent("SHENYU.COMMON.ADD.RULE")}
+ </Button>
+ </AuthButton>
+ <AuthButton perms={`plugin:${name}Rule:edit`}>
+ <Button
+ type="primary"
+ onClick={this.openRuleClick}
+ style={{ marginLeft: 10 }}
+ >
+ {getIntlContent(
+ ruleList.some(
+ (rule) =>
+ ruleSelectedRowKeys.includes(rule.id) &&
+ rule.enabled,
+ )
+ ? "SHENYU.PLUGIN.SELECTOR.BATCH.CLOSED"
+ : "SHENYU.PLUGIN.SELECTOR.BATCH.OPENED",
+ )}
+ </Button>
+ </AuthButton>
+ </div>
</div>
- </div>
- <Table
- size="small"
- style={{ marginTop: 30 }}
- bordered
- columns={rulesColumns}
- expandedRowRender={expandedRowRender}
- dataSource={ruleList}
- rowSelection={ruleRowSelection}
- pagination={{
- total: ruleTotal,
- showTotal: (showTotal) => `${showTotal}`,
- showSizeChanger: true,
- pageSizeOptions: ["12", "20", "50", "100"],
- current: rulePage,
- pageSize: rulePageSize,
- onChange: this.pageRuleChange,
- onShowSizeChange: this.pageRuleChangeSize,
- }}
- />
- </Col>
- </Row>
+ <Table
+ size="small"
+ style={{ marginTop: 30 }}
+ bordered
+ columns={rulesColumns}
+ expandedRowRender={expandedRowRender}
+ dataSource={ruleList}
+ rowSelection={ruleRowSelection}
+ pagination={{
+ total: ruleTotal,
+ showTotal: (showTotal) => `${showTotal}`,
+ showSizeChanger: true,
+ pageSizeOptions: ["12", "20", "50", "100"],
+ current: rulePage,
+ pageSize: rulePageSize,
+ onChange: this.pageRuleChange,
+ onShowSizeChange: this.pageRuleChangeSize,
+ }}
+ />
+ </Col>
+ </Row>
+ )}
{popup}
</div>
);
diff --git a/src/services/api.js b/src/services/api.js
index 3e5791a0..53cd67d8 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -1478,3 +1478,69 @@ export async function batchDeleteRegistry(params) {
body: [...params.list],
});
}
+
+/* ===== AI Proxy - API Key ===== */
+/**
+ * POST /selector/{selectorId}/ai-proxy-apikey
+ * body: { description?, enabled?, namespaceId }
+ */
+export async function addAiProxyApiKey(params) {
+ const { selectorId, description, enabled = true, namespaceId } = params;
+ return request(`${baseUrl}/selector/${selectorId}/ai-proxy-apikey`, {
+ method: `POST`,
+ body: { description, enabled, namespaceId },
+ });
+}
+
+/**
+ * GET
/selector/{selectorId}/ai-proxy-apikey?namespaceId=¤tPage=&pageSize=&proxyApiKey=
+ */
+export async function getAiProxyApiKeys(params) {
+ const { selectorId, namespaceId, currentPage, pageSize, proxyApiKey } =
+ params;
+ const query = stringify({ namespaceId, currentPage, pageSize, proxyApiKey });
+ return request(`${baseUrl}/selector/${selectorId}/ai-proxy-apikey?${query}`,
{
+ method: `GET`,
+ });
+}
+
+/**
+ * PUT /selector/{selectorId}/ai-proxy-apikey/{id}
+ */
+export async function updateAiProxyApiKey(params) {
+ const { selectorId, id, description, enabled, namespaceId } = params;
+ return request(`${baseUrl}/selector/${selectorId}/ai-proxy-apikey/${id}`, {
+ method: `PUT`,
+ body: { description, enabled, namespaceId },
+ });
+}
+
+/**
+ * POST /selector/{selectorId}/ai-proxy-apikey/batchDelete
+ * body: { ids: [] }
+ */
+export async function batchDeleteAiProxyApiKeys(params) {
+ const { selectorId, ids } = params;
+ return request(
+ `${baseUrl}/selector/${selectorId}/ai-proxy-apikey/batchDelete`,
+ {
+ method: `POST`,
+ body: { ids },
+ },
+ );
+}
+
+/**
+ * POST /selector/{selectorId}/ai-proxy-apikey/batchEnabled
+ * body: { ids: [], enabled: boolean }
+ */
+export async function batchEnableAiProxyApiKeys(params) {
+ const { selectorId, ids, enabled } = params;
+ return request(
+ `${baseUrl}/selector/${selectorId}/ai-proxy-apikey/batchEnabled`,
+ {
+ method: `POST`,
+ body: { ids, enabled },
+ },
+ );
+}