mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
优化了一些功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Task[]>([])
|
||||
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 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-[180px]">
|
||||
<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 className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{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">
|
||||
<span className="text-xs text-gray-500 mr-1">{selected.size} 个</span>
|
||||
@@ -216,6 +252,53 @@ export default function Containers() {
|
||||
</button>
|
||||
</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
|
||||
onClick={handleRefreshList}
|
||||
disabled={refreshing}
|
||||
@@ -267,7 +350,8 @@ export default function Containers() {
|
||||
{!isSubUser && (
|
||||
<input
|
||||
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}
|
||||
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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{displayContainers.map((container) => {
|
||||
{pageContainers.map((container) => {
|
||||
const isRunning = container.status === 'running'
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||
const isPlaceholder = !!container.isPlaceholder
|
||||
@@ -398,6 +482,50 @@ export default function Containers() {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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<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>) {
|
||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||
|
||||
@@ -35,6 +35,8 @@ export default function SubUserManagement() {
|
||||
const [modalTitle, setModalTitle] = useState('')
|
||||
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(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 (
|
||||
<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">
|
||||
{auditLogs.length === 0 ? (
|
||||
<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">
|
||||
<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>
|
||||
@@ -302,7 +313,7 @@ export default function SubUserManagement() {
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loginLogs.length === 0 ? (
|
||||
<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">
|
||||
<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>
|
||||
@@ -320,6 +331,34 @@ export default function SubUserManagement() {
|
||||
</table>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user