修复了一些已知问题

This commit is contained in:
MengMengCode
2026-06-07 20:16:00 +08:00
parent 08a1a057e7
commit 2ad17fa520
14 changed files with 549 additions and 79 deletions
+51 -13
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ArrowLeft,
AlertTriangle,
Camera,
Clock,
Copy,
@@ -299,8 +300,17 @@ export default function ContainerDetail() {
start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...',
}
const ensureSubUserCanOperate = async () => {
if (isSubUser && container?.policy_blocked) {
await dialog.alert('策略临时封禁', container.policy_blocked_reason || '虚拟机被策略临时封禁,暂不能执行操作。')
return false
}
return true
}
const handleAction = async (action: string) => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
setActionLoading(action)
try {
switch (action) {
@@ -462,11 +472,13 @@ export default function ContainerDetail() {
}
const openAddMapping = () => {
if (isSubUser && container?.policy_blocked) return
setDraft(emptyDraft)
setShowNat(true)
}
const openEditMapping = (pm: PortMapping, index: number) => {
if (isSubUser && container?.policy_blocked) return
if (isSubUser) {
// Sub-user: only edit container_port in a simple modal
setDraft({
@@ -489,6 +501,7 @@ export default function ContainerDetail() {
const submitMapping = async (): Promise<boolean> => {
if (!containerIdentifier) return false
if (!(await ensureSubUserCanOperate())) return false
if (draft.index === null && container) {
const currentCount = container.port_mappings?.length || 0
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
@@ -541,6 +554,7 @@ export default function ContainerDetail() {
const removeMapping = async (index: number) => {
if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return
if (!(await ensureSubUserCanOperate())) return
try {
await deletePortMapping(containerIdentifier, index)
await fetchContainer()
@@ -553,6 +567,7 @@ export default function ContainerDetail() {
const handleCreateSnapshot = async () => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (isSubUser && snapshots.length >= snapshotQuota) {
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
return
@@ -577,6 +592,7 @@ export default function ContainerDetail() {
}
const openSnapshotSchedule = () => {
if (isSubUser && container?.policy_blocked) return
setSnapshotScheduleDraft({
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
time: snapshotSchedule?.time || '03:00',
@@ -586,6 +602,7 @@ export default function ContainerDetail() {
const saveSnapshotSchedule = async (enabled: boolean) => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
const intervalHours = snapshotScheduleDraft.intervalHours
const scheduleTime = snapshotScheduleDraft.time || '03:00'
if (enabled && intervalHours < 24) {
@@ -625,6 +642,7 @@ export default function ContainerDetail() {
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
setSnapshotBusy(snapshot.id)
try {
@@ -640,6 +658,7 @@ export default function ContainerDetail() {
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
if (!containerIdentifier) return
if (!(await ensureSubUserCanOperate())) return
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
setSnapshotBusy(snapshot.id)
try {
@@ -681,6 +700,9 @@ export default function ContainerDetail() {
const isWindows = container.template?.includes('windows')
const canOpenVNC = isKVM && isRunning
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
const isPolicyBlocked = !!container.policy_blocked
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
@@ -703,7 +725,7 @@ export default function ContainerDetail() {
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
const mappingCount = container.port_mappings?.length || 0
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
const canAddMapping = isSubUser ? mappingCount < mappingLimit : true
const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: ''
@@ -774,34 +796,35 @@ export default function ContainerDetail() {
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
{isPolicyBlocked && <InfoTag color="red"></InfoTag>}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{!isRunning ? (
<ActionButton dark disabled={!!taskStatus || isExpired} onClick={() => handleAction('start')}>
<ActionButton dark disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('start')}>
<Play className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'}
</ActionButton>
) : (
<>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('stop')}>
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('stop')}>
<Square className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'}
</ActionButton>
<ActionButton disabled={!!taskStatus || isExpired} onClick={() => handleAction('restart')}>
<ActionButton disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked} onClick={() => handleAction('restart')}>
<RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
</ActionButton>
{!isWindows && (
<ActionButton dark onClick={() => setShowSSH(true)}>
<ActionButton dark disabled={isSubUserPolicyBlocked} onClick={() => setShowSSH(true)}>
<TerminalSquare className="w-3.5 h-3.5" />
WebSSH
</ActionButton>
)}
{isKVM && (
<ActionButton dark disabled={!canOpenVNC} onClick={() => setShowVNC(true)}>
<ActionButton dark disabled={!canOpenVNC || isSubUserPolicyBlocked} onClick={() => setShowVNC(true)}>
<Monitor className="w-3.5 h-3.5" />
WebVNC
</ActionButton>
@@ -815,12 +838,12 @@ export default function ContainerDetail() {
</ActionButton>
)}
<>
<ActionButton onClick={() => setShowNat(true)}>
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" />
NAT
</ActionButton>
</>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
<Camera className="w-3.5 h-3.5" />
</ActionButton>
@@ -840,9 +863,23 @@ export default function ContainerDetail() {
</div>
</div>
{isSubUserPolicyBlocked && (
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium"></div>
<div className="mt-1 text-xs text-red-600">{policyBlockedText}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel title="连接信息">
{isWindows ? (
{isSubUserPolicyBlocked ? (
<div className="rounded-md border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
</div>
) : isWindows ? (
<>
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
<PlainRow label="用户名" value="Administrator" mono />
@@ -1507,13 +1544,14 @@ function StatusBadge({ running }: { running: boolean }) {
)
}
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) {
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate' | 'red'; children: ReactNode }) {
const classes = {
blue: 'bg-blue-50 text-blue-700 border-blue-100',
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
amber: 'bg-amber-50 text-amber-700 border-amber-100',
violet: 'bg-violet-50 text-violet-700 border-violet-100',
slate: 'bg-slate-50 text-slate-700 border-slate-100',
red: 'bg-red-50 text-red-700 border-red-100',
}
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
}
+12 -2
View File
@@ -392,6 +392,7 @@ export default function Containers() {
const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
const usage = usageByName[container.name]
const isKVM = (container.virtualization || 'lxc') === 'kvm'
@@ -436,7 +437,7 @@ export default function Containers() {
</button>
</td>
<td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} />
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
@@ -580,8 +581,17 @@ type DisplayContainer = Container & {
createTask?: Task
}
function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) {
function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (policyBlocked) {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
)
}
if (task?.status === 'failed') {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
+156 -13
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import { getSecurityAlerts, SecurityAlert } from '../services/api'
import { FileText, Power, RefreshCw, X } from 'lucide-react'
import { getSecurityAlerts, getSecurityLogs, getSecuritySettings, SecurityAlert, SecurityLog, updateSecuritySettings } from '../services/api'
const typeLabels: Record<string, string> = {
port_scan: '端口扫描',
@@ -23,12 +23,18 @@ const severityLabels: Record<string, string> = {
export default function Security() {
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
const [autoShutdown, setAutoShutdown] = useState(false)
const [loading, setLoading] = useState(true)
const [savingSettings, setSavingSettings] = useState(false)
const [logAlert, setLogAlert] = useState<SecurityAlert | null>(null)
const [logs, setLogs] = useState<SecurityLog[]>([])
const [logsLoading, setLogsLoading] = useState(false)
const fetchData = useCallback(async () => {
try {
const alertRes = await getSecurityAlerts()
const [alertRes, settingsRes] = await Promise.all([getSecurityAlerts(), getSecuritySettings()])
if (alertRes.data.data) setAlerts(alertRes.data.data)
if (settingsRes.data.data) setAutoShutdown(settingsRes.data.data.auto_shutdown)
} catch (err) {
console.error(err)
} finally {
@@ -42,6 +48,36 @@ export default function Security() {
return () => clearInterval(interval)
}, [fetchData])
const handleAutoShutdownChange = async () => {
const next = !autoShutdown
setAutoShutdown(next)
setSavingSettings(true)
try {
const res = await updateSecuritySettings({ auto_shutdown: next })
if (res.data.data) setAutoShutdown(res.data.data.auto_shutdown)
} catch (err) {
console.error(err)
setAutoShutdown(!next)
} finally {
setSavingSettings(false)
}
}
const openLogs = async (alert: SecurityAlert) => {
setLogAlert(alert)
setLogs([])
setLogsLoading(true)
try {
const res = await getSecurityLogs(alert.container_name)
setLogs(filterRelatedLogs(res.data.data || [], alert))
} catch (err) {
console.error(err)
setLogs([])
} finally {
setLogsLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -52,15 +88,33 @@ export default function Security() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-black"></h1>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
role="switch"
aria-checked={autoShutdown}
onClick={handleAutoShutdownChange}
disabled={savingSettings}
title="告警自动关机"
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition-colors disabled:opacity-60 ${
autoShutdown
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Power className="w-4 h-4" />
<span>{autoShutdown ? '自动关机已开' : '自动关机已关'}</span>
</button>
<button
onClick={fetchData}
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
@@ -93,12 +147,24 @@ export default function Security() {
</td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
{formatTarget(alert)}
</td>
<td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[260px]">{alert.detail}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[300px]">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1">{alert.detail}</span>
<button
onClick={() => openLogs(alert)}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
title="查看相关记录"
>
<FileText className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
@@ -106,6 +172,69 @@ export default function Security() {
</div>
)}
</div>
{logAlert && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-start justify-between gap-3 border-b border-gray-200 px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-black"></h3>
<p className="mt-1 text-xs text-gray-500">
{logAlert.container_name} · {typeLabels[logAlert.type] || logAlert.type} · {formatTarget(logAlert)}
</p>
</div>
<button
onClick={() => setLogAlert(null)}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[70vh] overflow-auto">
{logAlert.log_line && (
<div className="border-b border-gray-100 bg-gray-50 px-4 py-3">
<div className="mb-1 text-xs font-medium text-gray-600"></div>
<pre className="whitespace-pre-wrap break-all rounded border border-gray-200 bg-white p-3 text-xs text-gray-700">{logAlert.log_line}</pre>
</div>
)}
{logsLoading ? (
<div className="p-8 text-center text-sm text-gray-500">...</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500">
conntrack
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map((log, index) => (
<tr key={`${log.src_ip}-${log.src_port}-${log.dst_ip}-${log.dst_port}-${index}`}>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.protocol || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700">{log.state || '-'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.src_ip, log.src_port)}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600">
{formatEndpoint(log.dst_ip, log.dst_port)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -129,3 +258,17 @@ function formatTarget(alert: SecurityAlert): string {
if (!alert.target_ip) return '-'
return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip
}
function filterRelatedLogs(logs: SecurityLog[], alert: SecurityAlert): SecurityLog[] {
return logs.filter((log) => {
if (alert.source_ip && log.src_ip !== alert.source_ip) return false
if (alert.target_ip && alert.target_ip !== '*' && log.dst_ip !== alert.target_ip) return false
if (alert.target_port > 0 && log.dst_port !== alert.target_port) return false
return true
})
}
function formatEndpoint(ip: string, port: number): string {
if (!ip) return '-'
return port > 0 ? `${ip}:${port}` : ip
}
+23 -1
View File
@@ -81,6 +81,9 @@ export interface Container {
snapshot_schedule_last_run: string
snapshot_schedule_next_run: string
snapshot_schedule_created_by: string
policy_blocked?: boolean
policy_blocked_reason?: string
policy_blocked_at?: string
}
export interface Template {
@@ -524,6 +527,19 @@ export interface SecuritySummary {
low: number
}
export interface SecuritySettings {
auto_shutdown: boolean
}
export interface SecurityLog {
src_ip: string
dst_ip: string
src_port: number
dst_port: number
protocol: string
state: string
}
export const getSecurityAlerts = () =>
api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
@@ -531,11 +547,17 @@ export const checkContainerSecurity = (containerName: string) =>
api.post<APIResponse>('/security/check', { container_name: containerName })
export const getSecurityLogs = (containerName: string) =>
api.get<APIResponse>('/security/logs', { params: { container: containerName } })
api.get<APIResponse<SecurityLog[]>>('/security/logs', { params: { container: containerName } })
export const getSecuritySummary = () =>
api.get<APIResponse<SecuritySummary>>('/security/summary')
export const getSecuritySettings = () =>
api.get<APIResponse<SecuritySettings>>('/security/settings')
export const updateSecuritySettings = (data: SecuritySettings) =>
api.put<APIResponse<SecuritySettings>>('/security/settings', data)
export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })