mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-08 06:24:44 +08:00
feat: split rate limits with configurable per-endpoint controls
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import api, { APIResponse, Container } from '../services/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
interface ApiKeyItem {
|
||||
@@ -238,6 +239,7 @@ const emptyForm = (): ApiKeyForm => ({
|
||||
})
|
||||
|
||||
export default function ApiIntegration() {
|
||||
const { t } = useLanguage()
|
||||
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
||||
const [containers, setContainers] = useState<Container[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -329,7 +331,7 @@ export default function ApiIntegration() {
|
||||
}
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
if (!window.confirm('确定删除这个 API Key 吗?')) return
|
||||
if (!window.confirm(t('确定删除这个 API Key 吗?'))) return
|
||||
try {
|
||||
await api.delete(`/api-keys/${id}`)
|
||||
setKeys(prev => prev.filter(k => k.id !== id))
|
||||
@@ -730,11 +732,15 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 100,
|
||||
network_up_mbps: 20,
|
||||
monthly_traffic_gb: 0,
|
||||
traffic_mode: 'total',
|
||||
traffic_in_gb: 0,
|
||||
traffic_out_gb: 0,
|
||||
io_speed_mbps: 0,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
extra_ports: [8080],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
@@ -765,8 +771,12 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'PUT /api/v1/containers/{id}/resource-limit': {
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
io_speed_mbps: 0,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 100,
|
||||
network_up_mbps: 20,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
network_bw_mbps: 20,
|
||||
io_speed_mbps: 30,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
||||
@@ -1098,11 +1108,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/swap': { success: true, message: 'SWAP 已调整为 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/swap': { success: true, message: 'SWAP adjusted to 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/batch-create': { success: true, data: ['task-12'] },
|
||||
'POST /api/v1/batch-action': { success: true, data: ['task-13'] },
|
||||
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
|
||||
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
|
||||
'POST /api/v1/sub-user/create': {
|
||||
success: true,
|
||||
message: 'Sub-user created',
|
||||
@@ -1167,29 +1177,33 @@ function examplePathFor(path: string) {
|
||||
function endpointNoteFor(key: string) {
|
||||
const notes: string[] = []
|
||||
if (key === 'POST /api/v1/containers') {
|
||||
notes.push('Linux 创建支持 ssh_auth_mode=auto_password|password|key;公网 IPv4、IPv6 与 NAT 可通过 assign_nat、assign_ipv4、assign_ipv6 组合使用。')
|
||||
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
||||
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/reinstall') {
|
||||
notes.push('重装支持 ssh_auth_mode=keep|auto_password|password|key;keep 仅用于重装,未传 SSH 字段时保持原有行为。')
|
||||
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-create') {
|
||||
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
|
||||
notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
||||
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/firewall') {
|
||||
notes.push('兼容旧请求:default_action 可不传,不传时保留现有策略;rule.network 可不传,不传按 ipv4 处理。default_action: DROP=未命中规则时拒绝, ACCEPT=未命中规则时放行。network: ipv4=IPv4 NAT/公网 IPv4, ipv6=IPv6, all=同时应用到 IPv4 和 IPv6。NAT 入站规则的 port 填容器内端口,不是宿主机公网端口。')
|
||||
notes.push('Backward compatible: default_action is optional; if omitted, the existing policy is kept. rule.network is optional; if omitted, it is treated as ipv4. default_action: DROP=deny unmatched traffic, ACCEPT=allow unmatched traffic. network: ipv4=IPv4 NAT/public IPv4, ipv6=IPv6, all=apply to both IPv4 and IPv6. For NAT inbound rules, port is the container internal port, not the host public port.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-action') {
|
||||
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
|
||||
notes.push('When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/routing') {
|
||||
notes.push('更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。')
|
||||
notes.push('Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
|
||||
}
|
||||
if (key === 'POST /api/v1/routing/ipv4-scan') {
|
||||
notes.push('扫描公网 IPv4 段需要 routing:write;verify=true 时会尝试校验地址可用性。')
|
||||
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||
}
|
||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。')
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。')
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。')
|
||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
|
||||
return notes.join(' ')
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function ContainerDetail() {
|
||||
const [trafficEdit, setTrafficEdit] = useState({ mode: 'total', monthly: 0, inGB: 0, outGB: 0 })
|
||||
const [savingTraffic, setSavingTraffic] = useState(false)
|
||||
const [showResourceEdit, setShowResourceEdit] = useState(false)
|
||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
|
||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, networkDownMbps: 0, networkUpMbps: 0, ioReadMbps: 0, ioWriteMbps: 0 })
|
||||
const [savingResource, setSavingResource] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showResetPassword, setShowResetPassword] = useState(false)
|
||||
@@ -395,8 +395,10 @@ export default function ContainerDetail() {
|
||||
setResourceEdit({
|
||||
vcpu: container.vcpu,
|
||||
ramMb: container.ram_mb,
|
||||
ioMbps: container.io_speed_mbps || 0,
|
||||
bwMbps: container.network_bw_mbps || 0,
|
||||
networkDownMbps: resourceLimitValue(container.network_down_mbps, container.network_bw_mbps),
|
||||
networkUpMbps: resourceLimitValue(container.network_up_mbps, container.network_bw_mbps),
|
||||
ioReadMbps: resourceLimitValue(container.io_read_mbps, container.io_speed_mbps),
|
||||
ioWriteMbps: resourceLimitValue(container.io_write_mbps, container.io_speed_mbps),
|
||||
})
|
||||
setShowResourceEdit(true)
|
||||
}
|
||||
@@ -408,8 +410,12 @@ export default function ContainerDetail() {
|
||||
await updateResourceLimit(container.id, {
|
||||
vcpu: resourceEdit.vcpu,
|
||||
ram_mb: resourceEdit.ramMb,
|
||||
io_speed_mbps: resourceEdit.ioMbps,
|
||||
network_bw_mbps: resourceEdit.bwMbps,
|
||||
network_down_mbps: resourceEdit.networkDownMbps,
|
||||
network_up_mbps: resourceEdit.networkUpMbps,
|
||||
network_bw_mbps: symmetricLimit(resourceEdit.networkDownMbps, resourceEdit.networkUpMbps),
|
||||
io_read_mbps: resourceEdit.ioReadMbps,
|
||||
io_write_mbps: resourceEdit.ioWriteMbps,
|
||||
io_speed_mbps: symmetricLimit(resourceEdit.ioReadMbps, resourceEdit.ioWriteMbps),
|
||||
})
|
||||
setShowResourceEdit(false)
|
||||
fetchContainer()
|
||||
@@ -903,8 +909,19 @@ export default function ContainerDetail() {
|
||||
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
|
||||
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
|
||||
const rx = usage?.network_rx_bps || 0
|
||||
const netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100)
|
||||
const networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps)
|
||||
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
|
||||
const netPct = Math.max(
|
||||
directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000),
|
||||
directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 125000, 125000000),
|
||||
)
|
||||
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
|
||||
const ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps)
|
||||
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
|
||||
const diskIOPct = Math.max(
|
||||
directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||
directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||
)
|
||||
const mappingCount = container.port_mappings?.length || 0
|
||||
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||
const hasNATQuota = mappingLimit > 0
|
||||
@@ -951,7 +968,7 @@ export default function ContainerDetail() {
|
||||
current: networkBps,
|
||||
points: toChartPoints(filtered, 'network'),
|
||||
formatValue: formatRate,
|
||||
detail: `入 ${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`,
|
||||
detail: `入 ${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`,
|
||||
},
|
||||
{
|
||||
title: '磁盘IO',
|
||||
@@ -959,7 +976,7 @@ export default function ContainerDetail() {
|
||||
current: diskIOBps,
|
||||
points: toChartPoints(filtered, 'diskIO'),
|
||||
formatValue: formatRate,
|
||||
detail: `读 ${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`,
|
||||
detail: `读 ${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1154,8 +1171,8 @@ export default function ContainerDetail() {
|
||||
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
||||
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
||||
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
||||
<PlainRow label="网络速率" value={container.network_bw_mbps > 0 ? `${container.network_bw_mbps} Mbps` : '不限制'} />
|
||||
<PlainRow label="IO 速度" value={container.io_speed_mbps > 0 ? `${container.io_speed_mbps} MB/s` : '不限制'} />
|
||||
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
|
||||
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="实时状态">
|
||||
@@ -1982,15 +1999,27 @@ export default function ContainerDetail() {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">网络速率 (Mbps,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.bwMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, bwMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
<label className="block text-xs text-gray-500 mb-1">下行带宽 (Mbps,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.networkDownMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, networkDownMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">IO 速度 (MB/s,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.ioMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, ioMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
<label className="block text-xs text-gray-500 mb-1">上行带宽 (Mbps,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.networkUpMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, networkUpMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">读取 IO (MB/s,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.ioReadMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, ioReadMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">写入 IO (MB/s,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.ioWriteMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, ioWriteMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2459,6 +2488,32 @@ function clampResourceInt(value: number, min: number, max?: number, fallback = m
|
||||
return Math.min(Math.max(next, min), max ?? next)
|
||||
}
|
||||
|
||||
function resourceLimitValue(value?: number, fallback?: number) {
|
||||
return Math.max(0, Number(value || fallback || 0))
|
||||
}
|
||||
|
||||
function symmetricLimit(a: number, b: number) {
|
||||
const left = resourceLimitValue(a)
|
||||
const right = resourceLimitValue(b)
|
||||
if (left === right) return left
|
||||
if (left === 0) return right
|
||||
if (right === 0) return left
|
||||
return Math.min(left, right)
|
||||
}
|
||||
|
||||
function directionUsagePercent(bytesPerSecond: number, limit: number, bytesPerLimitUnit: number, fallbackBytesPerSecond: number) {
|
||||
const denominator = limit > 0 ? limit * bytesPerLimitUnit : fallbackBytesPerSecond
|
||||
return denominator > 0 ? clamp((bytesPerSecond / denominator) * 100) : 0
|
||||
}
|
||||
|
||||
function formatLimit(value: number, unit: string) {
|
||||
return value > 0 ? `${value} ${unit}` : '不限制'
|
||||
}
|
||||
|
||||
function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLabel: string, secondValue: number, unit: string) {
|
||||
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
|
||||
}
|
||||
|
||||
function toChartPoints<T extends keyof Omit<MetricPoint, 'ts'>>(history: MetricPoint[], key: T): ChartPoint[] {
|
||||
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
|
||||
}
|
||||
|
||||
@@ -704,6 +704,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
ram_mb: cfg.ram_mb,
|
||||
disk_gb: cfg.disk_gb,
|
||||
network_bw_mbps: cfg.network_bw_mbps,
|
||||
network_down_mbps: cfg.network_down_mbps,
|
||||
network_up_mbps: cfg.network_up_mbps,
|
||||
monthly_traffic_gb: cfg.monthly_traffic_gb,
|
||||
traffic_mode: cfg.traffic_mode || 'total',
|
||||
traffic_in_gb: cfg.traffic_in_gb || 0,
|
||||
@@ -712,6 +714,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
traffic_used_tx: 0,
|
||||
traffic_reset_date: '',
|
||||
io_speed_mbps: cfg.io_speed_mbps,
|
||||
io_read_mbps: cfg.io_read_mbps,
|
||||
io_write_mbps: cfg.io_write_mbps,
|
||||
status: 'creating',
|
||||
ip: '',
|
||||
public_ipv4s: [],
|
||||
|
||||
Reference in New Issue
Block a user