Преглед изворни кода

feat: add channel metrics collector, fix search pagination and vendor sort order

- Register Prometheus ChannelCollector in GetMetrics() sync.Once block
- Add MetricsMiddleware to /v1 and /v1beta relay router groups when metrics enabled
- Remove public_name field from channel creation form and validation
- Restore InvitationCard in topup page
- Extract fetchSearchPage helper in useModelsData to eliminate ~50 lines of duplication; fix setSearching with finally block; use Axios params for URL encoding; fix missing vendor_counts update during paginated search
- Fix vendor ordering in HomePricingFilters to follow vendorsMap sort_order; fix O(n²) Array.includes to use Set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
fengsilin пре 2 недеља
родитељ
комит
3cf84775f2
8 измењених фајлова са 73 додато и 52 уклоњено
  1. +1
    -0
      common/metrics/metrics.go
  2. +6
    -0
      router/relay-router.go
  3. +0
    -1
      web/dist/static/login-widget.js
  4. +2
    -12
      web/src/components/table/channels/modals/EditChannelModal.jsx
  5. +1
    -9
      web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
  6. +2
    -2
      web/src/components/topup/index.jsx
  7. +44
    -21
      web/src/hooks/models/useModelsData.jsx
  8. +17
    -7
      web/src/pages/Home/HomePricingFilters.jsx

+ 1
- 0
common/metrics/metrics.go Прегледај датотеку

@@ -98,6 +98,7 @@ func GetMetrics() *Metrics {
return return
} }
globalMetrics = NewMetrics(prometheus.DefaultRegisterer) globalMetrics = NewMetrics(prometheus.DefaultRegisterer)
prometheus.MustRegister(NewChannelCollector())
}) })
return globalMetrics return globalMetrics
} }


+ 6
- 0
router/relay-router.go Прегледај датотеку

@@ -79,6 +79,9 @@ func SetRelayRouter(router *gin.Engine) {
relayV1Router := router.Group("/v1") relayV1Router := router.Group("/v1")
relayV1Router.Use(middleware.SystemPerformanceCheck()) relayV1Router.Use(middleware.SystemPerformanceCheck())
relayV1Router.Use(middleware.TokenAuth()) relayV1Router.Use(middleware.TokenAuth())
if metrics.IsEnabled() {
relayV1Router.Use(metrics.MetricsMiddleware(metrics.GetMetrics()))
}
relayV1Router.Use(middleware.ModelRequestRateLimit()) relayV1Router.Use(middleware.ModelRequestRateLimit())
relayV1Router.Use(middleware.RelayCaptureMiddleware()) relayV1Router.Use(middleware.RelayCaptureMiddleware())
{ {
@@ -165,6 +168,9 @@ func SetRelayRouter(router *gin.Engine) {
relayGeminiRouter := router.Group("/v1beta") relayGeminiRouter := router.Group("/v1beta")
relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
relayGeminiRouter.Use(middleware.TokenAuth()) relayGeminiRouter.Use(middleware.TokenAuth())
if metrics.IsEnabled() {
relayGeminiRouter.Use(metrics.MetricsMiddleware(metrics.GetMetrics()))
}
relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
relayGeminiRouter.Use(middleware.RelayCaptureMiddleware()) relayGeminiRouter.Use(middleware.RelayCaptureMiddleware())
relayGeminiRouter.Use(middleware.Distribute()) relayGeminiRouter.Use(middleware.Distribute())


+ 0
- 1
web/dist/static/login-widget.js Прегледај датотеку

@@ -1 +0,0 @@
var NewApiLoginWidget=function(s){"use strict";async function i({apiUrl:r,username:t,password:a}){if(!r)throw new Error("NewApiLoginWidget: apiUrl is required");if(!t||!t.trim())return{success:!1,message:"请输入用户名"};if(!a)return{success:!1,message:"请输入密码"};const n=r.replace(/\/+$/,"")+"/api/user/login";try{const e=await(await fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify({username:t.trim(),password:a})})).json();return e.success?e.data&&e.data.require_2fa?{success:!1,message:"此账户启用了两步验证,暂不支持通过 Widget 登录"}:{success:!0,data:e.data}:{success:!1,message:e.message||"登录失败"}}catch{return{success:!1,message:"网络错误,请检查网络连接"}}}return window.NewApiLoginWidget={login:i},s.login=i,Object.defineProperty(s,Symbol.toStringTag,{value:"Module"}),s}({});

+ 2
- 12
web/src/components/table/channels/modals/EditChannelModal.jsx Прегледај датотеку

@@ -1361,8 +1361,8 @@ const EditChannelModal = (props) => {
} }
delete localInputs.vertex_files; delete localInputs.vertex_files;


if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) {
showInfo(t('请填写渠道名称、对外名称和渠道密钥!'));
if (!isEdit && (!localInputs.name || !localInputs.key)) {
showInfo(t('请填写渠道名称和渠道密钥!'));
return; return;
} }
if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) { if (!Array.isArray(localInputs.models) || localInputs.models.length === 0) {
@@ -2014,16 +2014,6 @@ const EditChannelModal = (props) => {
autoComplete='new-password' autoComplete='new-password'
/> />


<Form.Input
field='public_name'
label={t('对外名称')}
placeholder={t('用户看到的渠道名称,如「标准通道」「高速通道」')}
rules={!isEdit ? [{ required: true, message: t('请填写对外名称') }] : []}
showClear
onChange={(value) => handleInputChange('public_name', value)}
autoComplete='new-password'
/>

{inputs.type === 33 && ( {inputs.type === 33 && (
<> <>
<Form.Select <Form.Select


+ 1
- 9
web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx Прегледај датотеку

@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */


import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Avatar, Typography, Table, Tag, Spin, Banner, Tooltip } from '@douyinfe/semi-ui';
import { Card, Avatar, Typography, Table, Tag, Spin, Tooltip } from '@douyinfe/semi-ui';
import { IconServer, IconCopy, IconArrowDown } from '@douyinfe/semi-icons'; import { IconServer, IconCopy, IconArrowDown } from '@douyinfe/semi-icons';
import { API, copy, showSuccess, getCurrencyConfig } from '../../../../../helpers'; import { API, copy, showSuccess, getCurrencyConfig } from '../../../../../helpers';


@@ -132,9 +132,6 @@ const ChannelPricingCard = ({
ID: {record.channelId} <IconCopy size='tiny' style={{ marginLeft: 2, verticalAlign: 'middle' }} /> ID: {record.channelId} <IconCopy size='tiny' style={{ marginLeft: 2, verticalAlign: 'middle' }} />
</Tag> </Tag>
</Tooltip> </Tooltip>
<Tag color='cyan' size='small' shape='circle'>
{text}
</Tag>
{record.channelTags.map((tag, idx) => ( {record.channelTags.map((tag, idx) => (
<Tag <Tag
key={idx} key={idx}
@@ -291,11 +288,6 @@ const ChannelPricingCard = ({
</div> </div>
</div> </div>
</div> </div>
<Banner
type='info'
description={t('在 API Key 后添加 ":通道ID" 可指定使用特定通道,如:sk-xxxx:1')}
className='mb-4'
/>
<Table <Table
dataSource={tableData} dataSource={tableData}
columns={columns} columns={columns}


+ 2
- 2
web/src/components/topup/index.jsx Прегледај датотеку

@@ -890,14 +890,14 @@ const TopUp = () => {
allSubscriptions={allSubscriptions} allSubscriptions={allSubscriptions}
reloadSubscriptionSelf={getSubscriptionSelf} reloadSubscriptionSelf={getSubscriptionSelf}
/> />
{/* <InvitationCard
<InvitationCard
t={t} t={t}
userState={userState} userState={userState}
renderQuota={renderQuota} renderQuota={renderQuota}
setOpenTransfer={setOpenTransfer} setOpenTransfer={setOpenTransfer}
affLink={affLink} affLink={affLink}
handleAffLinkClick={handleAffLinkClick} handleAffLinkClick={handleAffLinkClick}
/> */}
/>
</div> </div>
</div> </div>
); );


+ 44
- 21
web/src/hooks/models/useModelsData.jsx Прегледај датотеку

@@ -35,6 +35,9 @@ export const useModelsData = () => {
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [modelCount, setModelCount] = useState(0); const [modelCount, setModelCount] = useState(0);


// snapshot of the last *submitted* search — distinct from live form values, so pagination stays correct if the user edits the form without re-submitting
const [activeSearch, setActiveSearch] = useState({ keyword: '', vendor: '' });

// Modal states // Modal states
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [editingModel, setEditingModel] = useState({ const [editingModel, setEditingModel] = useState({
@@ -250,25 +253,16 @@ export const useModelsData = () => {
} }
}; };


// Search models with keyword and vendor
const searchModels = async () => {
const { searchKeyword = '', searchVendor = '' } = getFormValues();

if (searchKeyword === '' && searchVendor === '') {
// If keyword is blank, load models instead
await loadModels(1, pageSize);
return;
}

const fetchSearchPage = async (keyword, vendor, page, size) => {
setSearching(true); setSearching(true);
try { try {
const res = await API.get(
`/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`,
);
const res = await API.get('/api/models/search', {
params: { keyword, vendor, p: page, page_size: size },
});
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
const newPageData = extractItems(data); const newPageData = extractItems(data);
setActivePage(data.page || 1);
setActivePage(data.page || page);
setModelCount(data.total || newPageData.length); setModelCount(data.total || newPageData.length);
setModelFormat(newPageData); setModelFormat(newPageData);
if (data.vendor_counts) { if (data.vendor_counts) {
@@ -278,16 +272,36 @@ export const useModelsData = () => {
); );
setVendorCounts({ ...data.vendor_counts, all: sumAll }); setVendorCounts({ ...data.vendor_counts, all: sumAll });
} }
} else {
showError(message);
setModels([]);
return true;
} }
showError(message);
setModels([]);
return false;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
showError(t('搜索模型失败')); showError(t('搜索模型失败'));
setModels([]); setModels([]);
return false;
} finally {
setSearching(false);
}
};

// Search models with keyword and vendor
const searchModels = async () => {
const { searchKeyword = '', searchVendor = '' } = getFormValues();

if (searchKeyword === '' && searchVendor === '') {
// If keyword is blank, load models instead and clear search state
setActiveSearch({ keyword: '', vendor: '' });
await loadModels(1, pageSize);
return;
}

const ok = await fetchSearchPage(searchKeyword, searchVendor, 1, pageSize);
if (ok) {
setActiveSearch({ keyword: searchKeyword, vendor: searchVendor });
} }
setSearching(false);
}; };


// Manage model (enable/disable/delete) // Manage model (enable/disable/delete)
@@ -328,13 +342,18 @@ export const useModelsData = () => {
}; };


// Handle page change // Handle page change
const handlePageChange = (page) => {
const handlePageChange = async (page) => {
setActivePage(page); setActivePage(page);
loadModels(page, pageSize, activeVendorKey);
if (activeSearch.keyword || activeSearch.vendor) {
await fetchSearchPage(activeSearch.keyword, activeSearch.vendor, page, pageSize);
} else {
loadModels(page, pageSize, activeVendorKey);
}
}; };


// Reload models when activeVendorKey changes // Reload models when activeVendorKey changes
useEffect(() => { useEffect(() => {
setActiveSearch({ keyword: '', vendor: '' });
loadModels(1, pageSize, activeVendorKey); loadModels(1, pageSize, activeVendorKey);
}, [activeVendorKey]); }, [activeVendorKey]);


@@ -342,7 +361,11 @@ export const useModelsData = () => {
const handlePageSizeChange = async (size) => { const handlePageSizeChange = async (size) => {
setPageSize(size); setPageSize(size);
setActivePage(1); setActivePage(1);
await loadModels(1, size, activeVendorKey);
if (activeSearch.keyword || activeSearch.vendor) {
await fetchSearchPage(activeSearch.keyword, activeSearch.vendor, 1, size);
} else {
await loadModels(1, size, activeVendorKey);
}
}; };


// Handle row click and styling // Handle row click and styling


+ 17
- 7
web/src/pages/Home/HomePricingFilters.jsx Прегледај датотеку

@@ -77,12 +77,12 @@ const HomePricingFilters = ({ t }) => {


const vendorItems = useMemo(() => { const vendorItems = useMemo(() => {
const models = pricingData.models; const models = pricingData.models;
const vendorSet = new Set();
const vendorIcons = new Map(); const vendorIcons = new Map();
const vendorNamesInModels = new Set();
let hasUnknown = false; let hasUnknown = false;
(models || []).forEach((m) => { (models || []).forEach((m) => {
if (m.vendor_name) { if (m.vendor_name) {
vendorSet.add(m.vendor_name);
vendorNamesInModels.add(m.vendor_name);
if (m.vendor_icon && !vendorIcons.has(m.vendor_name)) { if (m.vendor_icon && !vendorIcons.has(m.vendor_name)) {
vendorIcons.set(m.vendor_name, m.vendor_icon); vendorIcons.set(m.vendor_name, m.vendor_icon);
} }
@@ -90,10 +90,20 @@ const HomePricingFilters = ({ t }) => {
hasUnknown = true; hasUnknown = true;
} }
}); });
const vendors = Array.from(vendorSet).sort();
// 按后端 vendorsMap 的 sort_order 顺序排列
const vMap = pricingData.vendorsMap || {};
const orderedVendors = Object.values(vMap)
.filter((v) => vendorNamesInModels.has(v.name))
.map((v) => v.name);
// fallback: vendors with models that are absent from vendorsMap (e.g. not yet registered)
const orderedVendorSet = new Set(orderedVendors);
vendorNamesInModels.forEach((name) => {
if (!orderedVendorSet.has(name)) {
orderedVendors.push(name);
}
});
const getVendorCount = () => { const getVendorCount = () => {
// 计算供应商数量(包含未知供应商)
const count = vendorSet.size + (hasUnknown ? 1 : 0);
const count = vendorNamesInModels.size + (hasUnknown ? 1 : 0);
return count; return count;
}; };
const getModelCount = (v) => { const getModelCount = (v) => {
@@ -104,7 +114,7 @@ const HomePricingFilters = ({ t }) => {
const items = [ const items = [
{ value: 'all', label: t('全部供应商'), tagCount: getVendorCount(), disabled: (models || []).length === 0 }, { value: 'all', label: t('全部供应商'), tagCount: getVendorCount(), disabled: (models || []).length === 0 },
]; ];
vendors.forEach((v) => {
orderedVendors.forEach((v) => {
const count = getModelCount(v); const count = getModelCount(v);
items.push({ items.push({
value: v, value: v,
@@ -123,7 +133,7 @@ const HomePricingFilters = ({ t }) => {
}); });
} }
return items; return items;
}, [pricingData.models, vendorModels, t]);
}, [pricingData.models, pricingData.vendorsMap, vendorModels, t]);


const typeItems = useMemo(() => { const typeItems = useMemo(() => {
const models = pricingData.models; const models = pricingData.models;


Loading…
Откажи
Сачувај