From 3488b6db56e9d6039b4bce26975d545120bf1c74 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:52:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/lxc/lxc.go | 61 +++++- frontend/src/pages/Containers.tsx | 244 +++++++++++++++++++++-- frontend/src/pages/SubUserManagement.tsx | 43 +++- 3 files changed, 322 insertions(+), 26 deletions(-) diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index a27e068..1aa3a4f 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -78,6 +78,9 @@ func (m *Manager) WarmRunningContainersSSH() { continue } config.UpdateContainerStatus(c.ID, "running") + if c.IP != "" && m.containerPortListening(c.LxcName(), 22) { + continue + } m.WarmSSHAsync(c.ID, "running container scan") } } @@ -383,6 +386,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { _ = m.cleanupContainerStorage(lxcName) + config.RemoveContainer(id) return err } @@ -459,13 +463,13 @@ IPv6AcceptRA=no // preconfigureSSH installs and configures SSH directly in the rootfs before first boot. func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error { _ = templateID - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("timed out after 120s, output: %s", string(output)) + return fmt.Errorf("timed out after 180s, output: %s", string(output)) } if err != nil { return fmt.Errorf("%v, output: %s", err, string(output)) @@ -985,6 +989,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if _, err := os.Stat(marker); err == nil { return nil } + m.unmountRootfsChildMounts(rootfsPath) + rootInfo, err := os.Lstat(rootfsPath) + if err != nil { + return err + } + rootStat, ok := rootInfo.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("failed to read rootfs device for %s", rootfsPath) + } + rootDev := rootStat.Dev if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { if walkErr != nil { @@ -998,6 +1012,12 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { if !ok { return fmt.Errorf("failed to read uid/gid for %s", path) } + if path != rootfsPath && stat.Dev != rootDev { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } uid := int(stat.Uid) gid := int(stat.Gid) if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { @@ -1032,6 +1052,34 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { return nil } +func (m *Manager) unmountRootfsChildMounts(rootfsPath string) { + rootAbs, err := filepath.Abs(rootfsPath) + if err != nil { + return + } + out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", rootfsPath).Output() + if err != nil { + return + } + targets := strings.Split(strings.TrimSpace(string(out)), "\n") + for i, j := 0, len(targets)-1; i < j; i, j = i+1, j-1 { + targets[i], targets[j] = targets[j], targets[i] + } + for _, target := range targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + targetAbs, err := filepath.Abs(target) + if err != nil || targetAbs == rootAbs { + continue + } + if strings.HasPrefix(targetAbs, rootAbs+string(os.PathSeparator)) { + exec.Command("umount", "-R", "-l", targetAbs).Run() + } + } +} + func (m *Manager) rootfsShifted(lxcName string) bool { marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted") _, err := os.Stat(marker) @@ -1527,12 +1575,12 @@ func (m *Manager) EnsureSSH(id int) error { script := sshSetupScript(c.SSHPassword, true) - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("timed out configuring SSH in container %d after 90s; package manager or service startup may be stuck, output: %s", id, string(output)) + return fmt.Errorf("timed out configuring SSH in container %d after 180s; package manager or service startup may be stuck, output: %s", id, string(output)) } if err != nil { return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output)) @@ -1640,7 +1688,10 @@ install_sshd() { sleep 3 done elif command -v apk >/dev/null 2>&1; then - run_timeout 60 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0 + for i in 1 2 3; do + run_timeout 120 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0 + sleep 3 + done elif command -v pacman >/dev/null 2>&1; then run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0 diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index d87cd6f..c0b6f9d 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import { ArrowDown, @@ -12,6 +12,7 @@ import { Plus, RefreshCw, RotateCcw, + Search, Server, Square, Trash2, @@ -46,6 +47,11 @@ export default function Containers() { const [showTasks, setShowTasks] = useState(false) const [tasks, setTasks] = useState([]) const [queuedCreates, setQueuedCreates] = useState>({}) + const [searchText, setSearchText] = useState('') + const [systemFilter, setSystemFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) const refreshUsage = useCallback(async (items: Container[]) => { const targets = items.filter((container) => container.status === 'running') @@ -102,18 +108,6 @@ export default function Containers() { }) } - const toggleAll = () => { - const selectableIDs = displayContainers - .filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]) - .map((container) => container.id) - - if (selected.size === selectableIDs.length) { - setSelected(new Set()) - } else { - setSelected(new Set(selectableIDs)) - } - } - // Map of container_id -> current task status. // For create tasks, container_id may be 0 initially but gets set after creation, // so we also index by container_name as fallback for placeholder items. @@ -170,6 +164,44 @@ export default function Containers() { const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks) const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length + const systemOptions = useMemo(() => buildSystemOptions(displayContainers), [displayContainers]) + const filteredContainers = useMemo(() => { + return filterContainers(displayContainers, { + search: searchText, + system: systemFilter, + status: statusFilter, + taskStatusMap, + taskNameMap, + }) + }, [displayContainers, searchText, systemFilter, statusFilter, tasks]) + const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize)) + const currentPage = Math.min(page, totalPages) + const pageStart = (currentPage - 1) * pageSize + const pageContainers = filteredContainers.slice(pageStart, pageStart + pageSize) + const selectableIDs = filteredContainers + .filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]) + .map((container) => container.id) + const allFilteredSelected = selectableIDs.length > 0 && selectableIDs.every((id) => selected.has(id)) + + useEffect(() => { + setPage(1) + }, [searchText, systemFilter, statusFilter, pageSize]) + + const toggleAll = () => { + if (allFilteredSelected) { + setSelected((prev) => { + const next = new Set(prev) + selectableIDs.forEach((id) => next.delete(id)) + return next + }) + } else { + setSelected((prev) => { + const next = new Set(prev) + selectableIDs.forEach((id) => next.add(id)) + return next + }) + } + } const handleCreateQueued = async (items: CreateContainerRequest[]) => { setQueuedCreates((current) => { @@ -193,12 +225,16 @@ export default function Containers() { return (
-
-
+
+

容器管理

-

共 {displayContainers.length} 个容器{selected.size > 0 && `,已选 ${selected.size} 个`}

+

+ 共 {displayContainers.length} 个容器 + {filteredContainers.length !== displayContainers.length && `,筛选后 ${filteredContainers.length} 个`} + {selected.size > 0 && `,已选 ${selected.size} 个`} +

-
+
{selected.size > 0 && (
{selected.size} 个 @@ -216,6 +252,53 @@ export default function Containers() {
)} + {displayContainers.length > 0 && ( + <> +
+ + setSearchText(event.target.value)} + className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black" + placeholder="搜索名称、ID、UUID、IP" + /> +
+ + + + + )}
+ {filteredContainers.length === 0 ? ( +
+ 没有匹配的容器 +
+ ) : ( +
+
+ 显示 {pageStart + 1}-{Math.min(pageStart + pageSize, filteredContainers.length)} / {filteredContainers.length} +
+
+ + + + {currentPage} / {totalPages} + + + +
+
+ )}
)} @@ -585,6 +713,84 @@ function hasActiveTasks(tasks: Task[]) { return tasks.some((task) => task.status === 'pending' || task.status === 'running') } +type ContainerFilters = { + search: string + system: string + status: string + taskStatusMap: Record + taskNameMap: Record +} + +function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] { + const keyword = filters.search.trim().toLowerCase() + return containers.filter((container) => { + const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask + if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) { + return false + } + if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) { + return false + } + if (!keyword) return true + + const fields = [ + String(container.id), + container.name, + container.uuid, + container.ip, + container.ipv6, + container.template, + getTemplateName(container.template), + getSystemFilterLabel(getSystemFilterValue(container.template)), + String(container.ssh_port || ''), + ] + return fields.some((field) => field.toLowerCase().includes(keyword)) + }) +} + +function buildSystemOptions(containers: DisplayContainer[]) { + const systems = new Map() + for (const container of containers) { + const value = getSystemFilterValue(container.template) + systems.set(value, getSystemFilterLabel(value)) + } + return Array.from(systems.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)) +} + +function getSystemFilterValue(template: string) { + if (template.startsWith('ubuntu')) return 'ubuntu' + if (template.startsWith('debian')) return 'debian' + if (template.startsWith('alpine')) return 'alpine' + if (template.startsWith('centos')) return 'centos' + if (template.startsWith('archlinux')) return 'archlinux' + if (template.startsWith('fedora')) return 'fedora' + if (template.startsWith('rockylinux')) return 'rockylinux' + return template || 'unknown' +} + +function getSystemFilterLabel(system: string) { + const labels: Record = { + ubuntu: 'Ubuntu', + debian: 'Debian', + alpine: 'Alpine', + centos: 'CentOS', + archlinux: 'Arch Linux', + fedora: 'Fedora', + rockylinux: 'Rocky Linux', + unknown: '未知系统', + } + return labels[system] || system +} + +function getContainerStatusFilterValue(container: DisplayContainer, task?: Task) { + if (task?.status === 'failed') return 'failed' + if (container.isPlaceholder || task?.type === 'create') return 'creating' + if (task && task.status !== 'done' && task.status !== 'failed') return 'task' + return container.status === 'running' ? 'running' : 'stopped' +} + function taskLineLabel(task: Task, actionLabels: Record) { if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败' if (task.type === 'create' && task.status === 'done') return '初始化完成' diff --git a/frontend/src/pages/SubUserManagement.tsx b/frontend/src/pages/SubUserManagement.tsx index 91a02c9..6399e52 100644 --- a/frontend/src/pages/SubUserManagement.tsx +++ b/frontend/src/pages/SubUserManagement.tsx @@ -35,6 +35,8 @@ export default function SubUserManagement() { const [modalTitle, setModalTitle] = useState('') const [passwordUser, setPasswordUser] = useState(null) const [rotatingPassword, setRotatingPassword] = useState(false) + const [logPage, setLogPage] = useState(1) + const [logPageSize, setLogPageSize] = useState(10) const fetchUsers = useCallback(async () => { try { @@ -82,6 +84,7 @@ export default function SubUserManagement() { setAuditLogs(res.data.data || []) setLoginLogs(null) setModalTitle(`${user.username} - 操作日志`) + setLogPage(1) } catch { dialog.alert('错误', '获取操作日志失败') } @@ -93,6 +96,7 @@ export default function SubUserManagement() { setLoginLogs(res.data.data || []) setAuditLogs(null) setModalTitle(`${user.username} - 登录日志`) + setLogPage(1) } catch { dialog.alert('错误', '获取登录日志失败') } @@ -103,6 +107,13 @@ export default function SubUserManagement() { setLoginLogs(null) } + const currentLogTotal = auditLogs?.length ?? loginLogs?.length ?? 0 + const logTotalPages = Math.max(1, Math.ceil(currentLogTotal / logPageSize)) + const currentLogPage = Math.min(logPage, logTotalPages) + const logStart = (currentLogPage - 1) * logPageSize + const currentAuditLogs = auditLogs?.slice(logStart, logStart + logPageSize) + const currentLoginLogs = loginLogs?.slice(logStart, logStart + logPageSize) + if (loading) { return (
@@ -267,7 +278,7 @@ export default function SubUserManagement() { {auditLogs.length === 0 ? ( 暂无操作日志 - ) : auditLogs.map((log, i) => ( + ) : currentAuditLogs?.map((log, i) => ( {log.time} {log.action} @@ -302,7 +313,7 @@ export default function SubUserManagement() { {loginLogs.length === 0 ? ( 暂无登录日志 - ) : loginLogs.map((log, i) => ( + ) : currentLoginLogs?.map((log, i) => ( {log.time} {log.ip} @@ -320,6 +331,34 @@ export default function SubUserManagement() { )}
+ {currentLogTotal > 0 && ( +
+
+ + 显示 {logStart + 1}-{Math.min(logStart + logPageSize, currentLogTotal)} / {currentLogTotal} + + +
+
+ + + {currentLogPage} / {logTotalPages} + + +
+
+ )}
)}