mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 422e48b524 | |||
| 3488b6db56 |
@@ -78,6 +78,9 @@ func (m *Manager) WarmRunningContainersSSH() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
config.UpdateContainerStatus(c.ID, "running")
|
config.UpdateContainerStatus(c.ID, "running")
|
||||||
|
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
m.WarmSSHAsync(c.ID, "running container scan")
|
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 {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
|
config.RemoveContainer(id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,13 +463,13 @@ IPv6AcceptRA=no
|
|||||||
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
||||||
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
|
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
|
||||||
_ = templateID
|
_ = templateID
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
|
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
|
||||||
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("%v, output: %s", err, string(output))
|
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 {
|
if _, err := os.Stat(marker); err == nil {
|
||||||
return 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 err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
@@ -998,6 +1012,12 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("failed to read uid/gid for %s", path)
|
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)
|
uid := int(stat.Uid)
|
||||||
gid := int(stat.Gid)
|
gid := int(stat.Gid)
|
||||||
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
|
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
|
||||||
@@ -1032,6 +1052,34 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
return nil
|
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 {
|
func (m *Manager) rootfsShifted(lxcName string) bool {
|
||||||
marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted")
|
marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted")
|
||||||
_, err := os.Stat(marker)
|
_, err := os.Stat(marker)
|
||||||
@@ -1527,12 +1575,12 @@ func (m *Manager) EnsureSSH(id int) error {
|
|||||||
|
|
||||||
script := sshSetupScript(c.SSHPassword, true)
|
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()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
|
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
|
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
|
sleep 3
|
||||||
done
|
done
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
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
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true
|
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
|
run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.0.9"
|
Version = "1.0.10"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,3 +15,4 @@ func Current() string {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Search,
|
||||||
Server,
|
Server,
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -46,6 +47,11 @@ export default function Containers() {
|
|||||||
const [showTasks, setShowTasks] = useState(false)
|
const [showTasks, setShowTasks] = useState(false)
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
|
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
|
||||||
|
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 refreshUsage = useCallback(async (items: Container[]) => {
|
||||||
const targets = items.filter((container) => container.status === 'running')
|
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.
|
// Map of container_id -> current task status.
|
||||||
// For create tasks, container_id may be 0 initially but gets set after creation,
|
// 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.
|
// 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 displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
|
||||||
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
|
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[]) => {
|
const handleCreateQueued = async (items: CreateContainerRequest[]) => {
|
||||||
setQueuedCreates((current) => {
|
setQueuedCreates((current) => {
|
||||||
@@ -193,12 +225,16 @@ export default function Containers() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-[180px]">
|
||||||
<h1 className="text-2xl font-bold text-black">容器管理</h1>
|
<h1 className="text-2xl font-bold text-black">容器管理</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">共 {displayContainers.length} 个容器{selected.size > 0 && `,已选 ${selected.size} 个`}</p>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
共 {displayContainers.length} 个容器
|
||||||
|
{filteredContainers.length !== displayContainers.length && `,筛选后 ${filteredContainers.length} 个`}
|
||||||
|
{selected.size > 0 && `,已选 ${selected.size} 个`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
|
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
|
||||||
<span className="text-xs text-gray-500 mr-1">{selected.size} 个</span>
|
<span className="text-xs text-gray-500 mr-1">{selected.size} 个</span>
|
||||||
@@ -216,6 +252,53 @@ export default function Containers() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{displayContainers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="relative w-[260px]">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={searchText}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={systemFilter}
|
||||||
|
onChange={(event) => setSystemFilter(event.target.value)}
|
||||||
|
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
title="系统筛选"
|
||||||
|
>
|
||||||
|
<option value="all">全部系统</option>
|
||||||
|
{systemOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
|
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
title="状态筛选"
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="running">在线</option>
|
||||||
|
<option value="stopped">离线</option>
|
||||||
|
<option value="task">任务中</option>
|
||||||
|
<option value="creating">创建中</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) => setPageSize(Number(event.target.value))}
|
||||||
|
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
title="每页数量"
|
||||||
|
>
|
||||||
|
<option value={10}>10 / 页</option>
|
||||||
|
<option value={20}>20 / 页</option>
|
||||||
|
<option value={50}>50 / 页</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshList}
|
onClick={handleRefreshList}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
@@ -267,7 +350,8 @@ export default function Containers() {
|
|||||||
{!isSubUser && (
|
{!isSubUser && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={displayContainers.length > 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length}
|
checked={allFilteredSelected}
|
||||||
|
disabled={selectableIDs.length === 0}
|
||||||
onChange={toggleAll}
|
onChange={toggleAll}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
|
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
|
||||||
/>
|
/>
|
||||||
@@ -287,7 +371,7 @@ export default function Containers() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{displayContainers.map((container) => {
|
{pageContainers.map((container) => {
|
||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||||
const isPlaceholder = !!container.isPlaceholder
|
const isPlaceholder = !!container.isPlaceholder
|
||||||
@@ -398,6 +482,50 @@ export default function Containers() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{filteredContainers.length === 0 ? (
|
||||||
|
<div className="border-t border-gray-100 px-4 py-10 text-center text-sm text-gray-500">
|
||||||
|
没有匹配的容器
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-4 py-3">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
显示 {pageStart + 1}-{Math.min(pageStart + pageSize, filteredContainers.length)} / {filteredContainers.length}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((value) => Math.max(1, value - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="px-2 text-xs text-gray-500">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
末页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -585,6 +713,84 @@ function hasActiveTasks(tasks: Task[]) {
|
|||||||
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
|
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContainerFilters = {
|
||||||
|
search: string
|
||||||
|
system: string
|
||||||
|
status: string
|
||||||
|
taskStatusMap: Record<number, Task>
|
||||||
|
taskNameMap: Record<string, Task>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>()
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string>) {
|
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
||||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export default function SubUserManagement() {
|
|||||||
const [modalTitle, setModalTitle] = useState('')
|
const [modalTitle, setModalTitle] = useState('')
|
||||||
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
||||||
const [rotatingPassword, setRotatingPassword] = useState(false)
|
const [rotatingPassword, setRotatingPassword] = useState(false)
|
||||||
|
const [logPage, setLogPage] = useState(1)
|
||||||
|
const [logPageSize, setLogPageSize] = useState(10)
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
const fetchUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +84,7 @@ export default function SubUserManagement() {
|
|||||||
setAuditLogs(res.data.data || [])
|
setAuditLogs(res.data.data || [])
|
||||||
setLoginLogs(null)
|
setLoginLogs(null)
|
||||||
setModalTitle(`${user.username} - 操作日志`)
|
setModalTitle(`${user.username} - 操作日志`)
|
||||||
|
setLogPage(1)
|
||||||
} catch {
|
} catch {
|
||||||
dialog.alert('错误', '获取操作日志失败')
|
dialog.alert('错误', '获取操作日志失败')
|
||||||
}
|
}
|
||||||
@@ -93,6 +96,7 @@ export default function SubUserManagement() {
|
|||||||
setLoginLogs(res.data.data || [])
|
setLoginLogs(res.data.data || [])
|
||||||
setAuditLogs(null)
|
setAuditLogs(null)
|
||||||
setModalTitle(`${user.username} - 登录日志`)
|
setModalTitle(`${user.username} - 登录日志`)
|
||||||
|
setLogPage(1)
|
||||||
} catch {
|
} catch {
|
||||||
dialog.alert('错误', '获取登录日志失败')
|
dialog.alert('错误', '获取登录日志失败')
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,13 @@ export default function SubUserManagement() {
|
|||||||
setLoginLogs(null)
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -267,7 +278,7 @@ export default function SubUserManagement() {
|
|||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{auditLogs.length === 0 ? (
|
{auditLogs.length === 0 ? (
|
||||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">暂无操作日志</td></tr>
|
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">暂无操作日志</td></tr>
|
||||||
) : auditLogs.map((log, i) => (
|
) : currentAuditLogs?.map((log, i) => (
|
||||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
|
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
|
||||||
<td className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
|
<td className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
|
||||||
@@ -302,7 +313,7 @@ export default function SubUserManagement() {
|
|||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{loginLogs.length === 0 ? (
|
{loginLogs.length === 0 ? (
|
||||||
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">暂无登录日志</td></tr>
|
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">暂无登录日志</td></tr>
|
||||||
) : loginLogs.map((log, i) => (
|
) : currentLoginLogs?.map((log, i) => (
|
||||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
|
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
|
||||||
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip}</td>
|
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip}</td>
|
||||||
@@ -320,6 +331,34 @@ export default function SubUserManagement() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{currentLogTotal > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>
|
||||||
|
显示 {logStart + 1}-{Math.min(logStart + logPageSize, currentLogTotal)} / {currentLogTotal}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={logPageSize}
|
||||||
|
onChange={(event) => {
|
||||||
|
setLogPageSize(Number(event.target.value))
|
||||||
|
setLogPage(1)
|
||||||
|
}}
|
||||||
|
className="h-7 rounded border border-gray-300 bg-white px-2 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value={10}>10 / 页</option>
|
||||||
|
<option value={20}>20 / 页</option>
|
||||||
|
<option value={50}>50 / 页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setLogPage(1)} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">首页</button>
|
||||||
|
<button onClick={() => setLogPage((page) => Math.max(1, page - 1))} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">上一页</button>
|
||||||
|
<span className="px-2 text-xs text-gray-500 dark:text-gray-400">{currentLogPage} / {logTotalPages}</span>
|
||||||
|
<button onClick={() => setLogPage((page) => Math.min(logTotalPages, page + 1))} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">下一页</button>
|
||||||
|
<button onClick={() => setLogPage(logTotalPages)} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">末页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user