Files
CLICD/frontend/src/pages/ApiIntegration.tsx
T
MengMengCode a45e063fc2 Add comprehensive documentation for CLICD features and operations
- Introduced Container Management documentation covering lifecycle operations, resource management, and console access.
- Added Dashboard documentation detailing metrics and related APIs.
- Created Host Report documentation summarizing host environment and resource status.
- Included Image Management documentation for template handling and management actions.
- Documented Networking and Routing features including NAT4 and IPv6 management.
- Added Security Alerts documentation outlining alert scenarios and API usage.
- Created Snapshot Management documentation for snapshot operations and scheduling.
- Documented Sub-user management for granting access to specific containers.
- Added Configuration guide detailing runtime settings and security recommendations.
- Created Installation guide for setting up CLICD with requirements and steps.
- Added Introduction and Quick Start guides for new users.
- Documented Upgrade process with version checking and pre-upgrade checklist.
- Created Deployment guide for service exposure and firewall recommendations.
- Added FAQ section addressing common questions and concerns.
- Documented Troubleshooting steps for common issues encountered.
2026-06-10 00:42:06 +08:00

1217 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Check,
ChevronDown,
ChevronUp,
Copy,
Edit3,
Eye,
Key,
Plus,
RefreshCw,
ShieldCheck,
Trash2,
X,
} from 'lucide-react'
import api, { APIResponse, Container } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface ApiKeyItem {
id: string
name: string
key?: string
prefix: string
ip_whitelist: string
created_at: string
last_used: string
scopes?: string[]
expires_at?: string
disabled?: boolean
container_uuids?: string[]
last_used_ip?: string
}
interface ApiKeyForm {
name: string
ipWhitelist: string
scopes: string[]
expiresAt: string
disabled: boolean
containerUUIDs: string[]
}
const BASE_URL = window.location.origin
const SAMPLE_BASE_URL = 'https://panel.example.com'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
type EndpointTuple = [HttpMethod, string, string]
interface EndpointDoc {
method: HttpMethod
path: string
desc: string
examplePath: string
body?: Record<string, unknown>
response: unknown
note?: string
}
const scopeGroups = [
{
title: '总览与只读',
scopes: [
['dashboard:read', '控制面板'],
['host:read', '主机资源'],
['routing:read', '路由信息'],
['routing:write', '路由配置'],
['ipv6:read', 'IPv6 状态'],
['task:read', '任务列表'],
['task:delete', '删除任务'],
['image:read', '镜像列表'],
],
},
{
title: '容器',
scopes: [
['container:read', '查看容器'],
['container:create', '创建容器'],
['container:power', '开关机/重启'],
['container:reinstall', '重装系统'],
['container:delete', '删除容器'],
['container:resize', '资源/到期'],
['container:traffic', '流量管理'],
['container:network', '端口映射'],
['container:password', '重置密码'],
['ipv6:assign', '分配 IPv6'],
],
},
{
title: '快照与终端',
scopes: [
['snapshot:read', '查看快照'],
['snapshot:create', '创建快照'],
['snapshot:delete', '删除快照'],
['snapshot:restore', '恢复快照'],
['snapshot:schedule', '计划/配额'],
['terminal:ssh', 'WebSSH 票据'],
['terminal:vnc', 'WebVNC 票据'],
],
},
{
title: '平台管理',
scopes: [
['image:download', '下载镜像'],
['image:delete', '删除镜像'],
['image:toggle', '启停镜像'],
['security:read', '安全数据'],
['security:check', '安全扫描'],
['security:settings', '安全设置'],
['swap:read', 'Swap 信息'],
['swap:manage', 'Swap 管理'],
['subuser:read', '子用户列表'],
['subuser:create', '创建子用户'],
['subuser:update', '更新子用户'],
['audit:read', '操作日志'],
['loginlog:read', '登录日志'],
['apikey:read', 'Key 列表'],
['apikey:create', '创建 Key'],
['apikey:update', '更新 Key'],
['apikey:delete', '删除 Key'],
['admin:access', '管理员接口'],
],
},
]
const defaultReadScopes = [
'dashboard:read',
'container:read',
'task:read',
'image:read',
'snapshot:read',
'routing:read',
'ipv6:read',
'host:read',
]
const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
{
title: '总览',
endpoints: [
['GET', '/api/v1/dashboard', '控制面板统计'],
['GET', '/api/v1/host-info', '主机资源'],
['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'],
['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'],
['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'],
['GET', '/api/v1/ipv6/status', 'IPv6 状态'],
['GET', '/api/v1/tasks', '任务队列'],
['DELETE', '/api/v1/tasks/{task_id}', '删除任务'],
],
},
{
title: '容器',
endpoints: [
['GET', '/api/v1/containers', '容器列表'],
['POST', '/api/v1/containers/list', '容器列表(兼容 POST 写法)'],
['POST', '/api/v1/containers', '创建容器'],
['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'],
['POST', '/api/v1/containers/{id}/start', '开机'],
['POST', '/api/v1/containers/{id}/stop', '关机'],
['POST', '/api/v1/containers/{id}/restart', '重启'],
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
['PUT', '/api/v1/containers/{id}/resource-limit', '调整资源限制'],
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
],
},
{
title: '端口与快照',
endpoints: [
['GET', '/api/v1/containers/{id}/random-port', '随机可用端口'],
['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'],
['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'],
['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'],
['GET', '/api/v1/snapshots', '快照总览'],
['GET', '/api/v1/containers/{id}/snapshots', '容器快照'],
['POST', '/api/v1/containers/{id}/snapshots', '创建快照'],
['DELETE', '/api/v1/containers/{id}/snapshots/{snapshot_id}', '删除快照'],
['POST', '/api/v1/containers/{id}/snapshots/{snapshot_id}/restore', '恢复快照'],
['POST', '/api/v1/containers/{id}/snapshots/schedule', '计划快照'],
['PUT', '/api/v1/containers/{id}/snapshots/quota', '快照配额'],
],
},
{
title: '平台管理',
endpoints: [
['GET', '/api/v1/templates', '模板列表'],
['GET', '/api/v1/images', '镜像管理列表'],
['POST', '/api/v1/images/download', '下载镜像'],
['POST', '/api/v1/images/cancel', '取消镜像下载'],
['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
['PUT', '/api/v1/images/toggle', '启用/禁用镜像'],
['GET', '/api/v1/security/alerts', '安全告警'],
['POST', '/api/v1/security/check', '立即安全检查'],
['GET', '/api/v1/security/logs?container={name}', '安全连接日志'],
['GET', '/api/v1/security/summary', '安全汇总'],
['GET', '/api/v1/security/settings', '安全设置'],
['PUT', '/api/v1/security/settings', '更新安全设置'],
['GET', '/api/v1/swap', 'Swap 信息'],
['POST', '/api/v1/swap', '调整 Swap'],
['POST', '/api/v1/batch-create', '批量创建容器'],
['POST', '/api/v1/batch-action', '批量开关机/删除/重装'],
['POST', '/api/v1/ssh-ticket', '创建 WebSSH 票据'],
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
],
},
{
title: '账号与日志',
endpoints: [
['POST', '/api/v1/sub-user/create', '创建子用户链接'],
['GET', '/api/v1/sub-users', '子用户列表'],
['POST', '/api/v1/sub-users/{id}/rotate-password', '轮换子用户密码'],
['GET', '/api/v1/sub-users/{id}/audit-logs', '子用户操作日志'],
['GET', '/api/v1/sub-users/{id}/login-logs', '子用户登录日志'],
['GET', '/api/v1/audit-logs', '操作日志'],
['GET', '/api/v1/login-logs', '登录日志'],
['GET', '/api/v1/api-keys', 'API Key 列表'],
['POST', '/api/v1/api-keys', '创建 API Key'],
['PATCH', '/api/v1/api-keys/{id}', '更新 API Key'],
['DELETE', '/api/v1/api-keys/{id}', '删除 API Key'],
],
},
]
const emptyForm = (): ApiKeyForm => ({
name: '',
ipWhitelist: '',
scopes: [...defaultReadScopes],
expiresAt: '',
disabled: false,
containerUUIDs: [],
})
export default function ApiIntegration() {
const [keys, setKeys] = useState<ApiKeyItem[]>([])
const [containers, setContainers] = useState<Container[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingKey, setEditingKey] = useState<ApiKeyItem | null>(null)
const [form, setForm] = useState<ApiKeyForm>(emptyForm)
const [saving, setSaving] = useState(false)
const [newKey, setNewKey] = useState('')
const [copiedKey, setCopiedKey] = useState(false)
const [showDocs, setShowDocs] = useState(true)
const [selectedEndpoint, setSelectedEndpoint] = useState<EndpointDoc | null>(null)
const [copiedDoc, setCopiedDoc] = useState(false)
const containerNameByUUID = useMemo(() => {
const map = new Map<string, string>()
containers.forEach(c => map.set(c.uuid, c.name))
return map
}, [containers])
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [keyRes, containerRes] = await Promise.all([
api.get<APIResponse<ApiKeyItem[]>>('/api-keys'),
api.get<APIResponse<Container[]>>('/containers'),
])
setKeys(keyRes.data.data || [])
setContainers(containerRes.data.data || [])
} catch {
// keep the page usable if one request fails
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const openCreate = () => {
setEditingKey(null)
setForm(emptyForm())
setShowForm(true)
}
const openEdit = (item: ApiKeyItem) => {
setEditingKey(item)
setForm({
name: item.name,
ipWhitelist: item.ip_whitelist || '',
scopes: item.scopes?.length ? item.scopes : ['*'],
expiresAt: toDateTimeLocal(item.expires_at || ''),
disabled: Boolean(item.disabled),
containerUUIDs: item.container_uuids || [],
})
setShowForm(true)
}
const saveKey = async () => {
if (!form.name.trim()) return
setSaving(true)
const payload = {
name: form.name.trim(),
ip_whitelist: form.ipWhitelist.trim(),
scopes: form.scopes,
expires_at: fromDateTimeLocal(form.expiresAt),
disabled: form.disabled,
container_uuids: form.containerUUIDs,
}
try {
if (editingKey) {
const res = await api.patch<APIResponse<ApiKeyItem>>(`/api-keys/${editingKey.id}`, payload)
if (res.data.data) {
setKeys(prev => prev.map(k => (k.id === editingKey.id ? res.data.data! : k)))
}
} else {
const res = await api.post<APIResponse<ApiKeyItem>>('/api-keys', payload)
if (res.data.data) {
setKeys(prev => [res.data.data!, ...prev])
if (res.data.data.key) setNewKey(res.data.data.key)
}
}
setShowForm(false)
} catch {
// axios interceptor handles auth; form stays open
} finally {
setSaving(false)
}
}
const deleteKey = async (id: string) => {
if (!window.confirm('确定删除这个 API Key 吗?')) return
try {
await api.delete(`/api-keys/${id}`)
setKeys(prev => prev.filter(k => k.id !== id))
} catch {
// ignore
}
}
const copyKey = async () => {
const copied = await copyToClipboard(newKey)
if (copied) {
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 1600)
}
}
const copyDocCode = async () => {
if (!selectedEndpoint) return
const copied = await copyToClipboard(buildPythonExample(selectedEndpoint))
if (copied) {
setCopiedDoc(true)
setTimeout(() => setCopiedDoc(false), 1600)
}
}
const toggleScope = (scope: string) => {
setForm(prev => {
if (scope === '*') {
return { ...prev, scopes: prev.scopes.includes('*') ? [...defaultReadScopes] : ['*'] }
}
const withoutAll = prev.scopes.filter(s => s !== '*')
const scopes = withoutAll.includes(scope)
? withoutAll.filter(s => s !== scope)
: [...withoutAll, scope]
return { ...prev, scopes: scopes.length ? scopes : [...defaultReadScopes] }
})
}
const toggleContainer = (uuid: string) => {
setForm(prev => ({
...prev,
containerUUIDs: prev.containerUUIDs.includes(uuid)
? prev.containerUUIDs.filter(item => item !== uuid)
: [...prev.containerUUIDs, uuid],
}))
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-black">API 集成</h1>
<p className="mt-1 text-sm text-gray-500">管理外部调用凭据、权限范围与平台 API 文档</p>
</div>
<button
onClick={openCreate}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800"
>
<Plus className="h-4 w-4" />
创建 Key
</button>
</div>
{newKey && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-amber-800">新的 API Key 已生成</div>
<button onClick={() => setNewKey('')} className="rounded p-1 text-amber-700 hover:bg-amber-100" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<code className="min-w-0 flex-1 break-all rounded border border-amber-300 bg-white px-3 py-2 font-mono text-xs text-gray-800">
{newKey}
</code>
<button
onClick={copyKey}
className="inline-flex items-center justify-center gap-1.5 rounded-md bg-amber-600 px-3 py-2 text-xs text-white hover:bg-amber-700"
>
{copiedKey ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copiedKey ? '已复制' : '复制'}
</button>
</div>
</div>
)}
<div className="rounded-lg border border-gray-200 bg-white">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Key className="h-4 w-4" />
API Keys
</h2>
<button onClick={fetchData} className="rounded p-1.5 text-gray-400 hover:text-black" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
{loading ? (
<div className="py-10 text-center text-sm text-gray-400">加载中...</div>
) : keys.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-400">暂无 API Key</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-3">名称</th>
<th className="px-4 py-3">权限</th>
<th className="px-4 py-3">绑定容器</th>
<th className="px-4 py-3">限制</th>
<th className="px-4 py-3">最后使用</th>
<th className="px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{keys.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{item.name}</span>
{item.disabled && (
<span className="rounded bg-red-50 px-1.5 py-0.5 text-[10px] font-medium text-red-600">已禁用</span>
)}
</div>
<div className="mt-1 font-mono text-xs text-gray-400">{item.prefix}</div>
</td>
<td className="px-4 py-3">
<ScopeSummary scopes={item.scopes || ['*']} />
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{item.container_uuids?.length
? item.container_uuids.map(uuid => containerNameByUUID.get(uuid) || uuid).join('、')
: '全部容器'}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
<div>{item.ip_whitelist ? 'IP 白名单' : '不限 IP'}</div>
<div>{item.expires_at ? `到期 ${item.expires_at}` : '长期有效'}</div>
</td>
<td className="px-4 py-3 text-xs text-gray-500">
<div>{item.last_used || '从未使用'}</div>
{item.last_used_ip && <div className="font-mono text-[11px] text-gray-400">{item.last_used_ip}</div>}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<button onClick={() => openEdit(item)} className="rounded p-1.5 text-gray-400 hover:text-black" title="编辑">
<Edit3 className="h-3.5 w-3.5" />
</button>
<button onClick={() => deleteKey(item.id)} className="rounded p-1.5 text-gray-400 hover:text-red-600" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="rounded-lg border border-gray-200 bg-white">
<button
onClick={() => setShowDocs(value => !value)}
className="flex w-full items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 text-left"
>
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<ShieldCheck className="h-4 w-4" />
API 文档
</h2>
{showDocs ? <ChevronUp className="h-4 w-4 text-gray-400" /> : <ChevronDown className="h-4 w-4 text-gray-400" />}
</button>
{showDocs && (
<div className="space-y-6 p-5">
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
<div>curl -X GET {BASE_URL}/api/v1/containers -H "X-API-Key: clicd_sk_xxxx"</div>
<div className="mt-2 text-gray-400">curl -X GET {BASE_URL}/api/v1/dashboard -H "Authorization: Bearer clicd_sk_xxxx"</div>
</div>
{endpointGroups.map(group => (
<section key={group.title}>
<h3 className="mb-2 text-sm font-semibold text-black">{group.title}</h3>
<div className="overflow-hidden rounded-lg border border-gray-200">
{group.endpoints.map(([method, path, desc]) => (
<div key={`${method}-${path}`} className="grid gap-2 border-b border-gray-100 px-3 py-2 text-xs last:border-b-0 md:grid-cols-[72px_minmax(220px,1fr)_180px_72px]">
<span className="w-fit rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono font-bold text-blue-700">{method}</span>
<code className="min-w-0 break-all font-mono text-gray-800">{path}</code>
<span className="text-gray-500">{desc}</span>
<button
onClick={() => setSelectedEndpoint(buildEndpointDoc(method, path, desc))}
className="inline-flex w-fit items-center justify-center gap-1 rounded border border-gray-200 px-2 py-1 text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-black"
title="查看使用范例"
>
<Eye className="h-3.5 w-3.5" />
查看
</button>
</div>
))}
</div>
</section>
))}
</div>
)}
</div>
{selectedEndpoint && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setSelectedEndpoint(null)} />
<div className="relative flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 font-mono text-xs font-bold text-blue-700">
{selectedEndpoint.method}
</span>
<code className="min-w-0 break-all font-mono text-sm text-gray-900">{selectedEndpoint.path}</code>
</div>
<p className="mt-1 text-xs text-gray-500">{selectedEndpoint.desc}</p>
</div>
<button onClick={() => setSelectedEndpoint(null)} className="shrink-0 rounded p-1 text-gray-400 hover:text-black" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="grid gap-4 lg:grid-cols-2">
<section className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-black">Python 使用范例</h3>
<button
onClick={copyDocCode}
className="inline-flex items-center gap-1 rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
>
{copiedDoc ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copiedDoc ? '已复制' : '复制'}
</button>
</div>
<pre className="max-h-[52vh] overflow-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
<code>{buildPythonExample(selectedEndpoint)}</code>
</pre>
</section>
<section className="min-w-0">
<h3 className="mb-2 text-sm font-semibold text-black">返回响应样例</h3>
{selectedEndpoint.note && (
<div className="mb-2 rounded border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">{selectedEndpoint.note}</div>
)}
<pre className="max-h-[52vh] overflow-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
<code>{formatJSON(selectedEndpoint.response)}</code>
</pre>
</section>
</div>
</div>
</div>
</div>
)}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowForm(false)} />
<div className="relative flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4">
<h3 className="text-base font-semibold text-black">{editingKey ? '编辑 API Key' : '创建 API Key'}</h3>
<button onClick={() => setShowForm(false)} className="rounded p-1 text-gray-400 hover:text-black" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="grid gap-5 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-4">
<label className="block">
<span className="mb-1 block text-xs text-gray-500">名称</span>
<input
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
placeholder="CI/CD、计费系统、自动化脚本"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-500">IP 白名单</span>
<textarea
value={form.ipWhitelist}
onChange={e => setForm(prev => ({ ...prev, ipWhitelist: e.target.value }))}
rows={4}
className="w-full resize-none rounded-md border border-gray-300 px-3 py-2 font-mono text-sm"
placeholder={`1.2.3.4\n10.0.0.0/24`}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-gray-500">过期时间</span>
<input
type="datetime-local"
value={form.expiresAt}
onChange={e => setForm(prev => ({ ...prev, expiresAt: e.target.value }))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={form.disabled}
onChange={e => setForm(prev => ({ ...prev, disabled: e.target.checked }))}
className="h-4 w-4 accent-black"
/>
禁用这个 Key
</label>
<div>
<div className="mb-2 text-xs text-gray-500">绑定容器</div>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-gray-200 p-2">
<label className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
<input
type="checkbox"
checked={form.containerUUIDs.length === 0}
onChange={() => setForm(prev => ({ ...prev, containerUUIDs: [] }))}
className="h-4 w-4 accent-black"
/>
全部容器
</label>
{containers.map(container => (
<label key={container.uuid} className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
<input
type="checkbox"
checked={form.containerUUIDs.includes(container.uuid)}
onChange={() => toggleContainer(container.uuid)}
className="h-4 w-4 accent-black"
/>
<span className="min-w-0 truncate">{container.name}</span>
<span className="shrink-0 font-mono text-[10px] text-gray-400">{container.uuid.slice(0, 8)}</span>
</label>
))}
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs text-gray-500">权限范围</div>
<button onClick={() => toggleScope('*')} className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50">
{form.scopes.includes('*') ? '取消全权限' : '全权限'}
</button>
</div>
<div className="space-y-4">
{scopeGroups.map(group => (
<div key={group.title}>
<div className="mb-2 text-xs font-medium text-gray-700">{group.title}</div>
<div className="grid gap-2 sm:grid-cols-2">
{group.scopes.map(([scope, label]) => (
<label key={scope} className="flex items-center gap-2 rounded border border-gray-200 px-2 py-2 text-xs text-gray-700 hover:bg-gray-50">
<input
type="checkbox"
checked={form.scopes.includes('*') || form.scopes.includes(scope)}
disabled={form.scopes.includes('*')}
onChange={() => toggleScope(scope)}
className="h-4 w-4 accent-black"
/>
<span className="min-w-0 flex-1 truncate">{label}</span>
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:block">{scope}</code>
</label>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-gray-200 px-5 py-4">
<button onClick={() => setShowForm(false)} className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50">
取消
</button>
<button
onClick={saveKey}
disabled={saving || !form.name.trim()}
className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
const requestBodySamples: Record<string, Record<string, unknown>> = {
'POST /api/v1/containers/list': {},
'POST /api/v1/containers': {
name: 'demo-lxc-01',
virtualization: 'lxc',
template_id: 'debian-bookworm',
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
network_bw_mbps: 0,
monthly_traffic_gb: 0,
traffic_mode: 'total',
traffic_in_gb: 0,
traffic_out_gb: 0,
io_speed_mbps: 0,
extra_ports: [8080],
port_mapping_count: 2,
assign_nat: true,
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
assign_ipv6: true,
ipv6_count: 1,
ipv6_addresses: [],
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
expires_at: '',
},
'POST /api/v1/containers/{id}/reinstall': {
template_id: 'debian-bookworm',
ssh_auth_mode: 'keep',
ssh_password: '',
ssh_public_key: '',
},
'PUT /api/v1/containers/{id}/traffic-limit': {
traffic_mode: 'total',
monthly_traffic_gb: 100,
traffic_in_gb: 0,
traffic_out_gb: 0,
},
'PUT /api/v1/containers/{id}/resource-limit': {
vcpu: 1,
ram_mb: 512,
io_speed_mbps: 0,
network_bw_mbps: 0,
},
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
'POST /api/v1/containers/{id}/port-mappings': {
container_port: 8080,
host_port: 61320,
protocol: 'tcp',
description: 'HTTP',
},
'PUT /api/v1/containers/{id}/port-mappings/{index}': {
container_port: 8081,
host_port: 61320,
protocol: 'tcp',
description: 'HTTP',
},
'POST /api/v1/containers/{id}/snapshots/schedule': {
enabled: true,
interval_hours: 24,
time: '03:00',
},
'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 },
'POST /api/v1/images/download': { template_id: 'debian-bookworm' },
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
'PUT /api/v1/routing': {
items: [
{
address: '203.0.113.10',
interface: 'eth0',
prefix_len: 32,
gateway: '203.0.113.1',
},
],
ipv6_prefixes: [
{
address: '2001:db8:100::2',
prefix: '2001:db8:100::/64',
prefix_len: 64,
interface: 'eth0',
gateway: '2001:db8:100::1',
},
],
},
'POST /api/v1/routing/ipv4-scan': {
cidr: '203.0.113.0/29',
interface: 'eth0',
gateway: '203.0.113.1',
verify: true,
limit: 64,
},
'POST /api/v1/security/check': { container_name: 'example-vm' },
'PUT /api/v1/security/settings': { auto_shutdown: false },
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
'POST /api/v1/batch-create': {
containers: [
{
name: 'batch-lxc-01',
virtualization: 'lxc',
template_id: 'debian-bookworm',
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
assign_nat: true,
port_mapping_count: 2,
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
assign_ipv6: true,
ipv6_count: 1,
ipv6_addresses: [],
ssh_auth_mode: 'key',
ssh_public_key: 'ssh-ed25519 AAAA... user@example',
},
],
},
'POST /api/v1/batch-action': {
action: 'reinstall',
containers: [5],
template_id: 'debian-bookworm',
ssh_auth_mode: 'keep',
ssh_password: '',
ssh_public_key: '',
},
'POST /api/v1/ssh-ticket': { container_name: 'example-vm' },
'POST /api/v1/vnc-ticket': { container_name: 'kvm-demo' },
'POST /api/v1/sub-user/create': { container_name: 'example-vm' },
'POST /api/v1/sub-users/{id}/rotate-password': {},
'POST /api/v1/api-keys': {
name: 'Automation',
ip_whitelist: '',
scopes: ['dashboard:read', 'container:read'],
expires_at: '',
disabled: false,
container_uuids: [],
},
'PATCH /api/v1/api-keys/{id}': {
name: 'Automation',
ip_whitelist: '',
scopes: ['dashboard:read', 'container:read'],
expires_at: '',
disabled: false,
container_uuids: [],
},
}
const responseSamples: Record<string, unknown> = {
'GET /api/v1/dashboard': { success: true, data: { running: 31, stopped: 0, total_containers: 31 } },
'GET /api/v1/host-info': {
success: true,
data: {
cpu: { cores: 8, usage_pct: 1.16 },
ram: { total_mb: 31825, used_mb: 1275, free_mb: 30550 },
disk: { total_gb: 1750.49, used_gb: 123.98, free_gb: 1626.51 },
network: {
public_ipv4: '203.0.113.10',
public_ipv4_interface: 'eth0',
public_ipv6: '2001:db8:100::2',
public_ipv6_interface: 'eth0',
},
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
},
},
'GET /api/v1/routing': {
success: true,
data: {
nat4: { used: 62, remaining: '45474', total: '45536' },
ipv4: { used: 1, remaining: '3', total: '4' },
ipv6: { used: 31, remaining: 'large', total: 'large' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
ipv4_assignments: [{ container_id: 5, container_name: 'example-vm', address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
nat4_mappings: [
{ container_id: 5, container_name: 'example-vm', status: 'running', ip: '10.0.0.10', host_port: 22004, container_port: 22, protocol: 'tcp' },
],
ipv6_assignments: [{ container_id: 5, container_name: 'example-vm', address: '2001:db8:100::1005', prefix_len: 64, interface: 'eth0' }],
},
},
'PUT /api/v1/routing': {
success: true,
data: {
ipv4: { used: 1, remaining: '3', total: '4' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
},
},
'POST /api/v1/routing/ipv4-scan': {
success: true,
data: [
{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1', status: 'available', usable: true, reason: '' },
],
},
'GET /api/v1/ipv6/status': {
success: true,
data: {
available: true,
reachable: true,
reason: 'usable public IPv6 prefix detected',
prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
},
},
'GET /api/v1/tasks': { success: true, data: [] },
'DELETE /api/v1/tasks/{task_id}': { success: true, message: 'Task deleted' },
'GET /api/v1/containers': {
success: true,
data: [
{
id: 5,
uuid: '00000000-0000-4000-8000-000000000005',
name: 'example-vm',
virtualization: 'lxc',
template: 'debian-bullseye',
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
status: 'running',
ip: '10.0.0.10',
ipv6: '2001:db8:100::1005',
ssh_port: 22004,
ssh_password: '***',
port_mappings: [
{ container_port: 22, host_port: 22004, protocol: 'tcp', description: 'SSH' },
{ container_port: 20000, host_port: 20000, protocol: 'tcp', description: 'Port-20000' },
],
},
],
},
'POST /api/v1/containers/list': {
success: true,
data: [
{ id: 5, uuid: '00000000-0000-4000-8000-000000000005', name: 'example-vm', status: 'running', ip: '10.0.0.10' },
],
},
'POST /api/v1/containers': { success: true, message: 'Container created successfully' },
'GET /api/v1/containers/{id|uuid|name}': {
success: true,
data: {
id: 5,
uuid: '00000000-0000-4000-8000-000000000005',
name: 'example-vm',
status: 'running',
ip: '10.0.0.10',
ipv6: '2001:db8:100::1005',
ssh_port: 22004,
ssh_password: '***',
policy_blocked: false,
},
},
'POST /api/v1/containers/{id}/start': queuedTaskSample('start'),
'POST /api/v1/containers/{id}/stop': queuedTaskSample('stop'),
'POST /api/v1/containers/{id}/restart': queuedTaskSample('restart'),
'POST /api/v1/containers/{id}/reinstall': queuedTaskSample('reinstall'),
'DELETE /api/v1/containers/{id}/delete': queuedTaskSample('delete'),
'GET /api/v1/containers/{id}/usage': {
success: true,
data: {
cpu_usage_pct: 0,
cpu_usage_usec: 3908852,
memory_usage_bytes: 29331456,
disk_usage_bytes: 515100672,
network_rx_bytes: 131232,
network_tx_bytes: 16828,
load1: 0.1,
load5: 0.06,
load15: 0.01,
},
},
'GET /api/v1/containers/{id}/traffic': {
success: true,
data: {
mode: 'total',
limit_gb: 0,
in_limit_gb: 0,
out_limit_gb: 0,
total_used_bytes: 142082,
rx_used_bytes: 127212,
tx_used_bytes: 14870,
used_pct: 0,
reset_date: '2026-06',
},
},
'POST /api/v1/containers/{id}/traffic-reset': { success: true, message: 'Traffic reset' },
'PUT /api/v1/containers/{id}/traffic-limit': { success: true, message: 'Traffic limit updated' },
'PUT /api/v1/containers/{id}/resource-limit': { success: true, message: 'Resource limits updated' },
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
'POST /api/v1/containers/{id}/port-mappings': {
success: true,
data: [
{ container_port: 22, host_port: 22004, protocol: 'tcp', description: 'SSH' },
{ container_port: 8080, host_port: 61320, protocol: 'tcp', description: 'HTTP' },
],
},
'PUT /api/v1/containers/{id}/port-mappings/{index}': {
success: true,
data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }],
},
'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] },
'GET /api/v1/snapshots': { success: true, data: null },
'GET /api/v1/containers/{id}/snapshots': {
success: true,
data: { quota: 1, schedule: { enabled: false, interval_hours: 0, last_run: '', next_run: '', time: '', created_by: '' }, snapshots: [] },
},
'POST /api/v1/containers/{id}/snapshots': {
success: true,
data: { id: 'snap-20260608-001', container_id: 5, container_name: 'example-vm', created_at: '2026-06-08 16:00:00', created_by: 'api:Automation', scheduled: false, size_bytes: 10485760 },
},
'DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}': { success: true, message: 'Snapshot deleted' },
'POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore': { success: true, message: 'Snapshot restored' },
'POST /api/v1/containers/{id}/snapshots/schedule': { success: true, data: { container: { id: 5, name: 'example-vm', snapshot_schedule_enabled: true, snapshot_schedule_interval_hours: 24, snapshot_schedule_time: '03:00' } } },
'PUT /api/v1/containers/{id}/snapshots/quota': { success: true, data: { quota: 2, container: { id: 5, name: 'example-vm', snapshot_limit: 2 } } },
'GET /api/v1/templates': {
success: true,
data: [
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', distro: 'ubuntu', release: 'noble', arch: 'amd64', description: 'Ubuntu 24.04 LTS' },
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', description: 'Debian 12 (Bookworm)' },
],
},
'GET /api/v1/images': {
success: true,
data: [
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
],
},
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
'GET /api/v1/security/alerts': { success: true, data: [] },
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
'GET /api/v1/security/summary': { success: true, data: { critical: 0, high: 0, low: 0, medium: 0, total_alerts: 0 } },
'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/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/sub-user/create': {
success: true,
message: 'Sub-user created',
data: { id: 'sub-xxxxxxxx', username: 'user-xxxxxxxx', password: '***', container_names: ['example-vm'], access_code: '********', created_at: '2026-06-08 16:00:00' },
},
'GET /api/v1/sub-users': { success: true, data: [] },
'POST /api/v1/sub-users/{id}/rotate-password': { success: true, data: { username: 'user-xxxxxxxx', password: '***', access_code: '********' } },
'GET /api/v1/sub-users/{id}/audit-logs': { success: true, data: [] },
'GET /api/v1/sub-users/{id}/login-logs': { success: true, data: [] },
'GET /api/v1/audit-logs': {
success: true,
data: [{ time: '2026-06-08 15:44:40', action: 'apikey.create', target: 'Test', detail: 'scopes=*', user: 'admin', success: true }],
},
'GET /api/v1/login-logs': {
success: true,
data: [{ time: '2026-06-08 08:24:00 UTC', username: 'admin', ip: '198.51.100.23', user_agent: 'Mozilla/5.0 ...', success: true }],
},
'GET /api/v1/api-keys': {
success: true,
data: [{ id: 'c271023f', name: 'Test', prefix: 'clicd_sk_dd9d...', ip_whitelist: '', created_at: '2026-06-08 15:44:40', last_used: '2026-06-08 15:46:10', scopes: ['*'], last_used_ip: '198.51.100.23' }],
},
'POST /api/v1/api-keys': {
success: true,
message: "API key created. Save this key now - it won't be shown again.",
data: { id: 'a1b2c3d4', name: 'Automation', key: 'clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', prefix: 'clicd_sk_xxxx...', scopes: ['dashboard:read', 'container:read'] },
},
'PATCH /api/v1/api-keys/{id}': { success: true, data: { id: 'a1b2c3d4', name: 'Automation', prefix: 'clicd_sk_xxxx...', scopes: ['dashboard:read', 'container:read'], disabled: false } },
'DELETE /api/v1/api-keys/{id}': { success: true, message: 'API key deleted' },
}
function queuedTaskSample(action: string) {
return {
success: true,
message: 'Task queued',
data: { task_id: 'task-10', container_name: 'example-vm', status: 'pending', action },
}
}
function buildEndpointDoc(method: HttpMethod, path: string, desc: string): EndpointDoc {
const key = `${method} ${path}`
return {
method,
path,
desc,
examplePath: examplePathFor(path),
body: requestBodySamples[key],
response: responseSamples[key] || defaultResponseFor(method),
note: endpointNoteFor(key),
}
}
function examplePathFor(path: string) {
return path
.replace('{id|uuid|name}', '5')
.replace('{id}', '5')
.replace('{task_id}', 'task-10')
.replace('{index}', '1')
.replace('{snapshot_id}', 'snap-20260608-001')
.replace('{name}', 'example-vm')
}
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 组合使用。')
}
if (key === 'POST /api/v1/containers/{id}/reinstall') {
notes.push('重装支持 ssh_auth_mode=keep|auto_password|password|keykeep 仅用于重装,未传 SSH 字段时保持原有行为。')
}
if (key === 'POST /api/v1/batch-create') {
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
}
if (key === 'POST /api/v1/batch-action') {
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
}
if (key === 'PUT /api/v1/routing') {
notes.push('更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。')
}
if (key === 'POST /api/v1/routing/ipv4-scan') {
notes.push('扫描公网 IPv4 段需要 routing:writeverify=true 时会尝试校验地址可用性。')
}
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('样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。')
return notes.join(' ')
}
function defaultResponseFor(method: HttpMethod) {
if (method === 'GET') return { success: true, data: [] }
if (method === 'DELETE') return { success: true, message: 'Deleted' }
return { success: true, message: 'OK' }
}
function buildPythonExample(doc: EndpointDoc) {
const hasBody = doc.body !== undefined && doc.method !== 'GET'
const bodyJSON = hasBody ? formatJSON(doc.body) : ''
return [
'import json',
'import requests',
'',
`BASE_URL = "${SAMPLE_BASE_URL}"`,
'API_KEY = "clicd_sk_xxxx"',
'',
...(hasBody ? [`payload = json.loads(r'''${bodyJSON}''')`, ''] : []),
`response = requests.${doc.method.toLowerCase()}(`,
` f"{BASE_URL}${doc.examplePath}",`,
' headers={"X-API-Key": API_KEY},',
...(hasBody ? [' json=payload,'] : []),
' timeout=30,',
')',
'response.raise_for_status()',
'print(response.json())',
].join('\n')
}
function formatJSON(value: unknown) {
return JSON.stringify(value, null, 2)
}
function ScopeSummary({ scopes }: { scopes: string[] }) {
if (scopes.includes('*')) {
return <span className="rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600">全权限</span>
}
const visible = scopes.slice(0, 3)
return (
<div className="flex max-w-xs flex-wrap gap-1">
{visible.map(scope => (
<span key={scope} className="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-600">
{scope}
</span>
))}
{scopes.length > visible.length && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">+{scopes.length - visible.length}</span>
)}
</div>
)
}
function toDateTimeLocal(value: string) {
if (!value) return ''
return value.replace(' ', 'T').slice(0, 16)
}
function fromDateTimeLocal(value: string) {
if (!value) return ''
return `${value.replace('T', ' ')}:00`
}