mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 007811ab41 | |||
| 95af3e44f2 | |||
| 2ad17fa520 | |||
| 08a1a057e7 | |||
| e9f657ab17 | |||
| 7e5da67de4 | |||
| c99f3f6d55 | |||
| 2df92be501 | |||
| f8d16ca792 | |||
| be17f669f7 | |||
| 65fc787070 | |||
| 245c57449c | |||
| 6dd7079e23 | |||
| 422e48b524 | |||
| 3488b6db56 | |||
| c7ba19fa34 | |||
| ffedf801e7 | |||
| 49d5a65357 | |||
| 8f32765ffe | |||
| 21b87d3d56 | |||
| 28f81a8f1c | |||
| 6dcd5bd06c | |||
| 8b402fcfa1 | |||
| 8dd01fe714 | |||
| a9784539ea | |||
| bb8a646de7 | |||
| a2da61e076 |
+1
Submodule .claude/worktrees/agent-ae3871aebda20eb86 added at 422e48b524
@@ -35,6 +35,15 @@ jobs:
|
||||
go-version: "1.22.x"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CLICD_VERSION=dev" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: bash build.sh
|
||||
|
||||
@@ -63,3 +63,6 @@ backend/tmp/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
linux.txt
|
||||
push-release.ps1
|
||||
deploy.ps1
|
||||
backend/clicd
|
||||
|
||||
@@ -11,79 +11,76 @@
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
|
||||
</p>
|
||||
|
||||
CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器,也适合需要批量创建和分发子用户管理链接的场景。
|
||||
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
|
||||
|
||||
## 功能介绍
|
||||
|
||||
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
|
||||
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
|
||||
3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
|
||||
4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
||||
5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关。
|
||||
6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
||||
7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
||||
8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
|
||||
9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
|
||||
10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
|
||||
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
||||
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
|
||||
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
|
||||
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
|
||||
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
|
||||
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
||||
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Backend: Go, net/http, LXC, cgroup v2, iptables, conntrack
|
||||
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
||||
- Runtime: Linux, systemd, LXC
|
||||
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
||||
- Build: GitHub Actions, Node.js 20, Go 1.22
|
||||
|
||||
## 安装
|
||||
|
||||
推荐使用最新 Release 一键安装。在目标服务器上执行:
|
||||
一键安装:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
也可以下载 GitHub Actions 构建出的 Release 产物 `clicd-linux-amd64.tar.gz` 后手动安装:
|
||||
|
||||
```bash
|
||||
tar -xzf clicd-linux-amd64.tar.gz
|
||||
cd clicd-linux-amd64
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
安装完成后访问:
|
||||
|
||||
```text
|
||||
http://YOUR_SERVER_IP:8999
|
||||
```
|
||||
|
||||
首次启动时会自动初始化管理员账号:
|
||||
|
||||
```text
|
||||
Username: admin
|
||||
Password: 随机 16 位密码
|
||||
```
|
||||
|
||||
安装脚本会尝试从 systemd 日志中输出初始账号密码。如果机器上已经存在 `/root/.clicd/config.json`,则不会重新生成密码。
|
||||
|
||||
查看初始密码日志:
|
||||
|
||||
```bash
|
||||
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:"
|
||||
```
|
||||
|
||||
卸载 CLICD:
|
||||
一键卸载:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
```
|
||||
|
||||
默认只删除 CLICD 服务和 `/usr/local/bin/clicd`,保留 `/root/.clicd` 配置数据和 `/var/lib/lxc` 容器。需要同时删除配置数据时:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall --purge-data
|
||||
```
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## Disclaimer/免责声明
|
||||
|
||||
This open-source software does not distribute Windows system images, nor does it provide any means to bypass or circumvent Windows activation mechanisms.
|
||||
|
||||
All download links provided within the software point to resources officially supplied by Microsoft. Users of this software are responsible for obtaining the appropriate licenses from Microsoft before using any Windows operating system downloaded through these links. This project does not bypass activation requirements for installed systems, nor does it assume any responsibility for the consequences of users' actions when using this software.
|
||||
|
||||
This open-source software is intended solely for educational purposes, specifically for learning the principles of LXC and KVM. The copyright for the Windows logo and related icons belongs to Microsoft/Windows.
|
||||
|
||||
本开源软件不提供任何 Windows 操作系统镜像的分发服务,也不包含任何绕过、破解或免除 Windows 激活机制的功能。
|
||||
|
||||
软件内涉及的 Windows 系统下载链接均由微软官方提供。使用者在下载、安装和使用相关 Windows 系统时,应自行向微软或其授权渠道购买并获得相应的软件许可。本项目不会对安装后的 Windows 系统进行任何形式的激活绕过、破解或免激活处理。
|
||||
|
||||
对于使用者因使用本软件而产生的任何行为及其后果,包括但不限于软件许可、系统使用、数据丢失、法律责任或其他相关问题,本项目及其开发者不承担任何责任。
|
||||
|
||||
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
|
||||
|
||||
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
|
||||
## Thanks/鸣谢
|
||||
|
||||
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
+23
-2
@@ -1,6 +1,8 @@
|
||||
module clicd
|
||||
|
||||
go 1.22.0
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
@@ -9,4 +11,23 @@ require (
|
||||
golang.org/x/term v0.28.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.29.0 // indirect
|
||||
require (
|
||||
golang.org/x/sys v0.31.0
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
modernc.org/strutil v1.2.1 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
+53
-2
@@ -1,10 +1,61 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
|
||||
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
|
||||
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
|
||||
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
@@ -11,8 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type ApiKey struct {
|
||||
@@ -116,6 +116,11 @@ func generateShortID() string {
|
||||
|
||||
// hashKey creates a simple hash for storage (not reversible)
|
||||
func hashKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func legacyHashKey(key string) string {
|
||||
b := make([]byte, 32)
|
||||
for i := range key {
|
||||
b[i%32] ^= key[i]
|
||||
@@ -126,8 +131,10 @@ func hashKey(key string) string {
|
||||
// validateApiKey checks if the given key is valid and IP is allowed
|
||||
func validateApiKey(rawKey, clientIP string) bool {
|
||||
hashed := hashKey(rawKey)
|
||||
legacyHashed := legacyHashKey(rawKey)
|
||||
for _, k := range config.AppConfig.ApiKeys {
|
||||
if k.KeyHash == hashed {
|
||||
if subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(hashed)) == 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(k.KeyHash), []byte(legacyHashed)) == 1 {
|
||||
if k.IPWhitelist == "" {
|
||||
return true
|
||||
}
|
||||
@@ -137,6 +144,29 @@ func validateApiKey(rawKey, clientIP string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func apiKeyFromRequest(r *http.Request) string {
|
||||
if apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")); apiKey != "" {
|
||||
return apiKey
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isValidApiKeyRequest(r *http.Request) bool {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" {
|
||||
return false
|
||||
}
|
||||
if !validateApiKey(apiKey, clientIP(r)) {
|
||||
return false
|
||||
}
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
return true
|
||||
}
|
||||
|
||||
// isIPAllowed checks if clientIP matches any entry in the whitelist
|
||||
func isIPAllowed(clientIP, whitelist string) bool {
|
||||
clientIP = strings.TrimSpace(clientIP)
|
||||
@@ -211,52 +241,15 @@ func updateApiKeyLastUsed(rawKey string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param
|
||||
// ApiKeyMiddleware authenticates requests via X-API-Key header or Authorization bearer.
|
||||
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check header
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey == "" {
|
||||
// Check query param
|
||||
apiKey = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if apiKey == "" {
|
||||
// Check Bearer token (some clients use this)
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
|
||||
apiKey = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
clientIP := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
clientIP = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP) {
|
||||
apiKey := apiKeyFromRequest(r)
|
||||
if apiKey == "" || !validateApiKey(apiKey, clientIP(r)) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a short-lived JWT so downstream admin middleware passes
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"username": config.AppConfig.AdminUser,
|
||||
"api_key": true,
|
||||
"exp": time.Now().Add(5 * time.Minute).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
tokenString, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
|
||||
|
||||
// Set cookie for subsequent requests
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "clicd_token",
|
||||
Value: tokenString,
|
||||
Path: "/",
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 300,
|
||||
})
|
||||
|
||||
updateApiKeyLastUsed(apiKey)
|
||||
next(w, r)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,27 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
||||
return nil, false
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// For sub-user tokens, check token_version against stored version (password rotation invalidation)
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
||||
tokenVersion := int(tokenVersionFloat)
|
||||
for i := range config.AppConfig.SubUsers {
|
||||
if config.AppConfig.SubUsers[i].Username == subUser {
|
||||
stored := config.AppConfig.SubUsers[i].TokenVersion
|
||||
// If stored version > 0, require token_version to match exactly.
|
||||
// This also rejects legacy tokens that lack token_version entirely.
|
||||
if stored > 0 && (!hasVersion || tokenVersion != stored) {
|
||||
return nil, false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
@@ -100,10 +121,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = forwarded
|
||||
}
|
||||
ip := clientIP(r)
|
||||
ua := r.Header.Get("User-Agent")
|
||||
|
||||
if req.Username != config.AppConfig.AdminUser {
|
||||
@@ -192,7 +210,7 @@ func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
||||
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenString := tokenFromRequest(r)
|
||||
if !isValidToken(tokenString) {
|
||||
if !isValidToken(tokenString) && !isValidApiKeyRequest(r) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
"clicd/internal/version"
|
||||
)
|
||||
|
||||
var lxcManager = lxc.NewManager()
|
||||
@@ -30,16 +31,35 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
c := containerByIdentifier(parts[0])
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
id := 0
|
||||
if c != nil {
|
||||
id = c.ID
|
||||
}
|
||||
id := c.ID
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
// Snapshot delete/restore operations: allow even if the container was deleted
|
||||
isSnapshotDelete := strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete
|
||||
isSnapshotRestore := strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost
|
||||
isSnapshotAction := isSnapshotDelete || isSnapshotRestore
|
||||
if !isSnapshotAction && c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if isSnapshotAction && id == 0 {
|
||||
// For orphaned snapshots, resolve containerID from the snapshot itself
|
||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||
snapshotID = strings.TrimSuffix(snapshotID, "/restore")
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
|
||||
return
|
||||
}
|
||||
id = snapshot.ContainerID
|
||||
}
|
||||
|
||||
switch {
|
||||
case action == "start" && r.Method == http.MethodPost:
|
||||
HandleSingleTaskAction(w, r, id, "start")
|
||||
@@ -69,6 +89,8 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
updateExpiry(w, r, id)
|
||||
case action == "ipv6" && r.Method == http.MethodPost:
|
||||
assignIPv6(w, r, id)
|
||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||
handleContainerSnapshots(w, r, id, action)
|
||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||
addPortMapping(w, r, id)
|
||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
||||
@@ -83,10 +105,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func listContainers(w http.ResponseWriter, r *http.Request) {
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
containers, _ := listByRuntime()
|
||||
containers = filterContainersForRequest(r, containers)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
|
||||
}
|
||||
@@ -101,10 +120,15 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
|
||||
return
|
||||
}
|
||||
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||
if cfg.TemplateID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
|
||||
return
|
||||
}
|
||||
if !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if cfg.VCPU <= 0 {
|
||||
cfg.VCPU = 1
|
||||
}
|
||||
@@ -121,7 +145,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -137,7 +164,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := lxcManager.CreateContainer(cfg); err != nil {
|
||||
if err := createByRuntime(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -150,11 +177,15 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if c.IsKVM() && c.Status == "running" {
|
||||
_, _ = kvmManager.RefreshVNCPort(c.ID)
|
||||
_, _ = kvmManager.RefreshNetwork(c.ID)
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
|
||||
}
|
||||
|
||||
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
||||
usage, err := lxcManager.GetResourceUsage(id)
|
||||
usage, err := usageByRuntime(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -163,7 +194,7 @@ func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
|
||||
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
|
||||
info := lxcManager.GetTrafficInfo(id)
|
||||
info := trafficByRuntime(id)
|
||||
if info == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
@@ -252,7 +283,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
if req.RAMMB > 0 {
|
||||
nextRAMMB = req.RAMMB
|
||||
}
|
||||
if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -265,13 +296,17 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
|
||||
// Re-apply resource limits to running container
|
||||
if c.Status == "running" {
|
||||
if err := lxcManager.ApplyContainerLimits(c); err != nil {
|
||||
if err := applyLimitsByRuntime(c); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Resource limits updated"})
|
||||
msg := "Resource limits updated"
|
||||
if c.IsKVM() && c.Status == "running" {
|
||||
msg = "资源已保存,请关机重启虚拟机后生效"
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg})
|
||||
}
|
||||
|
||||
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||
@@ -311,6 +346,10 @@ func HandleTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) {
|
||||
HandleEnabledImages(w, r)
|
||||
return
|
||||
}
|
||||
templates := lxc.GetTemplates()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
|
||||
}
|
||||
@@ -321,10 +360,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
containers, _ := listByRuntime()
|
||||
running := 0
|
||||
stopped := 0
|
||||
for _, c := range containers {
|
||||
@@ -358,7 +394,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
|
||||
return
|
||||
}
|
||||
newPassword, err := lxcManager.ResetSSHPassword(id)
|
||||
newPassword, err := resetPasswordByRuntime(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -438,3 +474,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
||||
}
|
||||
|
||||
// HandleVersion returns the current CLICD version.
|
||||
func HandleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"version": version.Current(),
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"clicd/internal/lxc"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type HostInfo struct {
|
||||
@@ -135,8 +136,8 @@ func getMemoryInfo() MemoryInfo {
|
||||
}
|
||||
|
||||
func getDiskInfo() DiskInfo {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs("/", &stat); err != nil {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs("/", &stat); err != nil {
|
||||
// Try command-based fallback
|
||||
cmd := exec.Command("df", "-BG", "/")
|
||||
output, err := cmd.Output()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
type ImageInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
@@ -25,6 +27,7 @@ type ImageInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Downloading bool `json:"downloading"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ManualPath string `json:"manual_path,omitempty"`
|
||||
}
|
||||
|
||||
var imageDownloadsMu sync.Mutex
|
||||
@@ -78,6 +81,9 @@ func getEnabledImageSet() map[string]bool {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
set[t.ID] = true
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
set[t.ID] = true
|
||||
}
|
||||
} else {
|
||||
for _, id := range config.AppConfig.EnabledImages {
|
||||
set[id] = true
|
||||
@@ -93,16 +99,17 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
enabledSet := getEnabledImageSet()
|
||||
|
||||
images := make([]ImageInfo, 0, len(templates))
|
||||
templates := lxc.GetTemplates()
|
||||
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
|
||||
for _, t := range templates {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationLXC,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
@@ -113,6 +120,28 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
SizeBytes: size,
|
||||
})
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
_, downloading := imageDownloads[t.ID]
|
||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||
manualPath := ""
|
||||
if t.Distro == "windows" {
|
||||
manualPath = kvm.ImagePath(t.ID)
|
||||
}
|
||||
images = append(images, ImageInfo{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Type: config.VirtualizationKVM,
|
||||
Distro: t.Distro,
|
||||
Release: t.Release,
|
||||
Arch: t.Arch,
|
||||
Description: t.Description,
|
||||
Downloaded: downloaded,
|
||||
Enabled: enabledSet[t.ID],
|
||||
Downloading: downloading,
|
||||
SizeBytes: size,
|
||||
ManualPath: manualPath,
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
||||
}
|
||||
@@ -134,7 +163,37 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
image := kvm.FindImage(req.TemplateID)
|
||||
if image == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||
ensureImageEnabled(image.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||
return
|
||||
}
|
||||
imageDownloadsMu.Lock()
|
||||
if imageDownloads[req.TemplateID] {
|
||||
imageDownloadsMu.Unlock()
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||
return
|
||||
}
|
||||
imageDownloads[req.TemplateID] = true
|
||||
imageDownloadsMu.Unlock()
|
||||
defer func() {
|
||||
imageDownloadsMu.Lock()
|
||||
delete(imageDownloads, req.TemplateID)
|
||||
imageDownloadsMu.Unlock()
|
||||
}()
|
||||
ensureImageEnabled(image.ID)
|
||||
if err := kvm.DownloadImage(*image); err != nil {
|
||||
message := "Download failed: " + err.Error()
|
||||
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,6 +265,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
if image := kvm.FindImage(req.TemplateID); image != nil {
|
||||
if err := kvm.DeleteImage(image.ID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
|
||||
return
|
||||
}
|
||||
removeImageEnabled(image.ID)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
@@ -259,19 +327,56 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||
enabledSet := getEnabledImageSet()
|
||||
|
||||
result := make([]lxc.Template, 0)
|
||||
for _, t := range templates {
|
||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
||||
result = append(result, t)
|
||||
result := make([]map[string]string, 0)
|
||||
if runtime == config.VirtualizationKVM {
|
||||
for _, t := range kvm.GetImages() {
|
||||
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"description": t.Description, "type": config.VirtualizationKVM,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||
}
|
||||
|
||||
func isTemplateEnabledAndDownloaded(templateID string) bool {
|
||||
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
|
||||
}
|
||||
|
||||
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||
runtime = runtimeFromRequest(runtime)
|
||||
if runtime == config.VirtualizationKVM {
|
||||
image := kvm.FindImage(templateID)
|
||||
if image == nil {
|
||||
return false
|
||||
}
|
||||
enabledSet := getEnabledImageSet()
|
||||
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
|
||||
return enabledSet[image.ID] && downloaded
|
||||
}
|
||||
tmpl := lxc.FindTemplate(templateID)
|
||||
if tmpl == nil {
|
||||
return false
|
||||
}
|
||||
enabledSet := getEnabledImageSet()
|
||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
}
|
||||
|
||||
func ensureImageEnabled(id string) {
|
||||
// If the enabled list is empty, all templates are currently enabled by default.
|
||||
// We must populate the list with all template IDs first so that explicit toggles stick.
|
||||
@@ -279,6 +384,9 @@ func ensureImageEnabled(id string) {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
config.SaveConfig()
|
||||
return // Already contains all IDs including this one
|
||||
}
|
||||
@@ -304,6 +412,11 @@ func removeImageEnabled(id string) {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
if t.ID != id {
|
||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||
}
|
||||
}
|
||||
config.SaveConfig()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c, err := lxcManager.AssignIPv6(id)
|
||||
c, err := assignIPv6ByRuntime(id)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// HandleOversell handles GET/POST for oversell config
|
||||
func HandleOversell(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
getOversell(w, r)
|
||||
case http.MethodPost:
|
||||
updateOversell(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func getOversell(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: config.AppConfig.Oversell})
|
||||
}
|
||||
|
||||
func updateOversell(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg config.OversellConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Apply KSM
|
||||
if cfg.KSMEnabled {
|
||||
exec.Command("sh", "-c", "echo 1 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
|
||||
exec.Command("sh", "-c", "echo 1000 > /sys/kernel/mm/ksm/sleep_millisecs 2>/dev/null").Run()
|
||||
} else {
|
||||
exec.Command("sh", "-c", "echo 0 > /sys/kernel/mm/ksm/run 2>/dev/null").Run()
|
||||
}
|
||||
|
||||
// Apply swappiness
|
||||
if cfg.Swappiness >= 0 && cfg.Swappiness <= 100 {
|
||||
exec.Command("sh", "-c", fmt.Sprintf("echo %d > /proc/sys/vm/swappiness", cfg.Swappiness)).Run()
|
||||
}
|
||||
|
||||
// Oversell multipliers are capacity-planning values. They must not increase
|
||||
// an individual container's CPU or RAM limits.
|
||||
reapplyContainerLimits()
|
||||
|
||||
config.AppConfig.Oversell = cfg
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Oversell config updated", Data: cfg})
|
||||
}
|
||||
|
||||
// reapplyContainerLimits restores cgroup limits for all running containers from
|
||||
// their assigned container resources.
|
||||
func reapplyContainerLimits() {
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
if err := lxcManager.ApplyContainerLimits(&c); err != nil {
|
||||
fmt.Printf("Warning: failed to reapply resource limits for %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOversellStatus returns current oversell resource usage
|
||||
func HandleOversellStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
status := map[string]interface{}{
|
||||
"ksm_active": isKSMEnabled(),
|
||||
"ksm_pages": getKSMPages(),
|
||||
"ksm_supported": isKSMSupported(),
|
||||
"swappiness": getSwappiness(),
|
||||
"reclaim_supported": isMemoryReclaimSupported(),
|
||||
"allocated_cpu": getAllocatedCPU(),
|
||||
"allocated_ram_mb": getAllocatedRAM(),
|
||||
"allocated_disk_gb": getAllocatedDisk(),
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||
}
|
||||
|
||||
// HandleOversellReclaim triggers one cgroup v2 memory.reclaim pass for running containers.
|
||||
func HandleOversellReclaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
result := reclaimContainerMemory()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Memory reclaim triggered", Data: result})
|
||||
}
|
||||
|
||||
func reclaimContainerMemory() map[string]interface{} {
|
||||
attempted := 0
|
||||
reclaimed := 0
|
||||
unsupported := 0
|
||||
errors := make([]string, 0)
|
||||
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
reclaimPath := findMemoryReclaimPath(c.LxcName())
|
||||
if reclaimPath == "" {
|
||||
unsupported++
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(reclaimPath, []byte("64M"), 0644); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", c.Name, err))
|
||||
continue
|
||||
}
|
||||
reclaimed++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"attempted": attempted,
|
||||
"reclaimed": reclaimed,
|
||||
"unsupported": unsupported,
|
||||
"errors": errors,
|
||||
}
|
||||
}
|
||||
|
||||
func isKSMEnabled() bool {
|
||||
data, err := os.ReadFile("/sys/kernel/mm/ksm/run")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(data)) == "1"
|
||||
}
|
||||
|
||||
func isKSMSupported() bool {
|
||||
if _, err := os.Stat("/sys/kernel/mm/ksm/run"); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getKSMPages() int64 {
|
||||
data, err := os.ReadFile("/sys/kernel/mm/ksm/pages_shared")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
return val
|
||||
}
|
||||
|
||||
func getSwappiness() int {
|
||||
data, err := os.ReadFile("/proc/sys/vm/swappiness")
|
||||
if err != nil {
|
||||
return 60
|
||||
}
|
||||
val, _ := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
return val
|
||||
}
|
||||
|
||||
func isMemoryReclaimSupported() bool {
|
||||
if _, err := os.Stat("/sys/fs/cgroup/memory.reclaim"); err == nil {
|
||||
return true
|
||||
}
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
if findMemoryReclaimPath(c.LxcName()) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findMemoryReclaimPath(lxcName string) string {
|
||||
candidates := []string{
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.reclaim", lxcName),
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.reclaim", lxcName),
|
||||
fmt.Sprintf("/sys/fs/cgroup/system.slice/lxc@%s.service/memory.reclaim", lxcName),
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getAllocatedCPU() float64 {
|
||||
total := 0.0
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
total += c.VCPU
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func getAllocatedRAM() int64 {
|
||||
total := int64(0)
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
total += int64(c.RAMMB)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func getAllocatedDisk() int64 {
|
||||
total := int64(0)
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
total += int64(c.DiskGB)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
type routeCapacity struct {
|
||||
Used int `json:"used"`
|
||||
Remaining string `json:"remaining"`
|
||||
Total string `json:"total"`
|
||||
}
|
||||
|
||||
type nat4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
HostPort int `json:"host_port"`
|
||||
ContainerPort int `json:"container_port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ipv6Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
Address string `json:"address"`
|
||||
PrefixLen int `json:"prefix_len"`
|
||||
Interface string `json:"interface"`
|
||||
}
|
||||
|
||||
type routingResponse struct {
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
nat4Mappings := make([]nat4Route, 0)
|
||||
usedPorts := map[int]bool{}
|
||||
ipv6Assignments := make([]ipv6Route, 0)
|
||||
|
||||
const nat4StartPort = 20000
|
||||
const nat4EndPort = 65535
|
||||
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
for _, pm := range c.PortMappings {
|
||||
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
||||
usedPorts[pm.HostPort] = true
|
||||
}
|
||||
nat4Mappings = append(nat4Mappings, nat4Route{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
IP: c.IP,
|
||||
HostPort: pm.HostPort,
|
||||
ContainerPort: pm.ContainerPort,
|
||||
Protocol: pm.Protocol,
|
||||
Description: pm.Description,
|
||||
})
|
||||
}
|
||||
if c.IPv6 != "" {
|
||||
ipv6Assignments = append(ipv6Assignments, ipv6Route{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
Address: c.IPv6,
|
||||
PrefixLen: c.IPv6PrefixLen,
|
||||
Interface: c.IPv6Interface,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(nat4Mappings, func(i, j int) bool {
|
||||
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
|
||||
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
|
||||
}
|
||||
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
|
||||
})
|
||||
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||
})
|
||||
|
||||
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
|
||||
nat4Used := len(usedPorts)
|
||||
nat4Remaining := totalNAT4Ports - nat4Used
|
||||
if nat4Remaining < 0 {
|
||||
nat4Remaining = 0
|
||||
}
|
||||
|
||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||
ipv6Total := "0"
|
||||
ipv6Remaining := "0"
|
||||
if len(prefixes) > 0 {
|
||||
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
|
||||
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: routingResponse{
|
||||
NAT4: routeCapacity{
|
||||
Used: nat4Used,
|
||||
Remaining: strconv.Itoa(nat4Remaining),
|
||||
Total: strconv.Itoa(totalNAT4Ports),
|
||||
},
|
||||
IPv6: routeCapacity{
|
||||
Used: len(ipv6Assignments),
|
||||
Remaining: ipv6Remaining,
|
||||
Total: ipv6Total,
|
||||
},
|
||||
NAT4Mappings: nat4Mappings,
|
||||
IPv6Assignments: ipv6Assignments,
|
||||
IPv6Prefixes: prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func subtractCapacity(total string, used int) string {
|
||||
if total == "" || total == "0" {
|
||||
return "0"
|
||||
}
|
||||
if total == "large" {
|
||||
return "large"
|
||||
}
|
||||
parsed, err := strconv.ParseInt(total, 10, 64)
|
||||
if err != nil {
|
||||
return total
|
||||
}
|
||||
remaining := parsed - int64(used)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return strconv.FormatInt(remaining, 10)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
var kvmManager = kvm.NewManager()
|
||||
|
||||
func runtimeFromRequest(value string) string {
|
||||
return config.NormalizeVirtualization(value)
|
||||
}
|
||||
|
||||
func runtimeFromTemplateID(templateID string) string {
|
||||
if kvm.FindImage(templateID) != nil {
|
||||
return config.VirtualizationKVM
|
||||
}
|
||||
return config.VirtualizationLXC
|
||||
}
|
||||
|
||||
func createByRuntime(cfg lxc.ContainerConfig) error {
|
||||
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||
if cfg.Virtualization == config.VirtualizationKVM {
|
||||
return kvmManager.CreateContainer(cfg)
|
||||
}
|
||||
return lxcManager.CreateContainer(cfg)
|
||||
}
|
||||
|
||||
func startByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.StartContainer(id)
|
||||
}
|
||||
return lxcManager.StartContainer(id)
|
||||
}
|
||||
|
||||
func stopByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.StopContainer(id)
|
||||
}
|
||||
return lxcManager.StopContainer(id)
|
||||
}
|
||||
|
||||
func restartByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.RestartContainer(id)
|
||||
}
|
||||
return lxcManager.RestartContainer(id)
|
||||
}
|
||||
|
||||
func destroyByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.DestroyContainer(id)
|
||||
}
|
||||
return lxcManager.DestroyContainer(id)
|
||||
}
|
||||
|
||||
func reinstallByRuntime(id int, templateID string) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ReinstallContainer(id, templateID)
|
||||
}
|
||||
return lxcManager.ReinstallContainer(id, templateID)
|
||||
}
|
||||
|
||||
func resetPasswordByRuntime(id int) (string, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ResetSSHPassword(id)
|
||||
}
|
||||
return lxcManager.ResetSSHPassword(id)
|
||||
}
|
||||
|
||||
func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.AssignIPv6(id)
|
||||
}
|
||||
return lxcManager.AssignIPv6(id)
|
||||
}
|
||||
|
||||
func usageByRuntime(id int) (map[string]interface{}, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.GetResourceUsage(id)
|
||||
}
|
||||
return lxcManager.GetResourceUsage(id)
|
||||
}
|
||||
|
||||
func trafficByRuntime(id int) map[string]interface{} {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.GetTrafficInfo(id)
|
||||
}
|
||||
return lxcManager.GetTrafficInfo(id)
|
||||
}
|
||||
|
||||
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
}
|
||||
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
}
|
||||
|
||||
func deleteSnapshotByRuntime(snapshotID string) error {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot != nil {
|
||||
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
|
||||
return kvmManager.DeleteSnapshot(snapshotID)
|
||||
}
|
||||
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
|
||||
return kvmManager.DeleteSnapshot(snapshotID)
|
||||
}
|
||||
}
|
||||
return lxcManager.DeleteSnapshot(snapshotID)
|
||||
}
|
||||
|
||||
func restoreSnapshotByRuntime(snapshotID string) error {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot != nil {
|
||||
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
|
||||
return kvmManager.RestoreSnapshot(snapshotID)
|
||||
}
|
||||
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
|
||||
return kvmManager.RestoreSnapshot(snapshotID)
|
||||
}
|
||||
}
|
||||
return lxcManager.RestoreSnapshot(snapshotID)
|
||||
}
|
||||
|
||||
func setSnapshotScheduleByRuntime(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
|
||||
}
|
||||
return lxcManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
|
||||
}
|
||||
|
||||
func applyLimitsByRuntime(c *config.Container) error {
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ApplyContainerLimits(c)
|
||||
}
|
||||
return lxcManager.ApplyContainerLimits(c)
|
||||
}
|
||||
|
||||
func listByRuntime() ([]config.Container, error) {
|
||||
containers, err := lxcManager.ListContainers()
|
||||
if err != nil {
|
||||
containers = config.AppConfig.Containers
|
||||
}
|
||||
containers = kvmManager.ListContainers(containers)
|
||||
return containers, err
|
||||
}
|
||||
|
||||
func validateRuntimeResourceRequest(runtime string, vcpu float64, ramMB int, diskGB int) error {
|
||||
if runtime == config.VirtualizationKVM {
|
||||
if vcpu < 1 || math.Abs(vcpu-math.Round(vcpu)) > 0.000001 {
|
||||
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
||||
}
|
||||
}
|
||||
return validateContainerResourceRequest(vcpu, ramMB, diskGB)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -557,10 +558,10 @@ func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, port
|
||||
|
||||
func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-5 * time.Minute)
|
||||
shouldShutdown := false
|
||||
|
||||
for i := range ss.alerts {
|
||||
a := &ss.alerts[i]
|
||||
@@ -579,6 +580,11 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
|
||||
if severityRank(severity) > severityRank(a.Severity) {
|
||||
a.Severity = severity
|
||||
}
|
||||
shouldShutdown = config.AppConfig.SecurityAutoShutdown
|
||||
ss.mu.Unlock()
|
||||
if shouldShutdown {
|
||||
autoShutdownAlertContainer(name, alertType, severity)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -599,10 +605,16 @@ func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP stri
|
||||
|
||||
ss.alerts = append(ss.alerts, alert)
|
||||
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
|
||||
shouldShutdown = config.AppConfig.SecurityAutoShutdown
|
||||
|
||||
if len(ss.alerts) > 200 {
|
||||
ss.alerts = ss.alerts[len(ss.alerts)-200:]
|
||||
}
|
||||
ss.mu.Unlock()
|
||||
|
||||
if shouldShutdown {
|
||||
autoShutdownAlertContainer(name, alertType, severity)
|
||||
}
|
||||
}
|
||||
|
||||
func severityRank(severity string) int {
|
||||
@@ -620,6 +632,22 @@ func severityRank(severity string) int {
|
||||
}
|
||||
}
|
||||
|
||||
func autoShutdownAlertContainer(containerName, alertType, severity string) {
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil || c.Status != "running" {
|
||||
return
|
||||
}
|
||||
reason := fmt.Sprintf("%s 告警触发策略临时封禁", alertType)
|
||||
if severity != "" {
|
||||
reason = fmt.Sprintf("[%s] %s", severity, reason)
|
||||
}
|
||||
config.SetContainerPolicyBlock(c.ID, true, reason)
|
||||
taskID, queued := globalQueue.EnqueueSecurityStop(c.ID, c.Name)
|
||||
if queued {
|
||||
config.AddAuditLog("security_auto_shutdown", c.Name, fmt.Sprintf("[%s] %s 告警触发自动关机任务 %s", severity, alertType, taskID), "system")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSecurityAlerts returns all security alerts.
|
||||
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -627,19 +655,35 @@ func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
reversed := make([]SecurityAlert, len(ss.alerts))
|
||||
for i, a := range ss.alerts {
|
||||
reversed[len(ss.alerts)-1-i] = a
|
||||
}
|
||||
ss.mu.Unlock()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mergedSecurityAlerts()})
|
||||
}
|
||||
|
||||
if reversed == nil {
|
||||
reversed = []SecurityAlert{}
|
||||
// HandleSecuritySettings returns or updates security automation settings.
|
||||
func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
}})
|
||||
case http.MethodPut:
|
||||
var req struct {
|
||||
AutoShutdown bool `json:"auto_shutdown"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
config.AppConfig.SecurityAutoShutdown = req.AutoShutdown
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
}})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
|
||||
}
|
||||
|
||||
// HandleSecurityCheck triggers immediate security check for a container.
|
||||
@@ -738,13 +782,12 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
critical := 0
|
||||
high := 0
|
||||
medium := 0
|
||||
low := 0
|
||||
for _, a := range ss.alerts {
|
||||
alerts := mergedSecurityAlerts()
|
||||
for _, a := range alerts {
|
||||
switch a.Severity {
|
||||
case "critical":
|
||||
critical++
|
||||
@@ -756,8 +799,7 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
low++
|
||||
}
|
||||
}
|
||||
total := len(ss.alerts)
|
||||
ss.mu.Unlock()
|
||||
total := len(alerts)
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"total_alerts": total,
|
||||
@@ -769,3 +811,117 @@ func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
|
||||
}
|
||||
|
||||
func mergedSecurityAlerts() []SecurityAlert {
|
||||
ss := ensureScanner()
|
||||
ss.mu.Lock()
|
||||
alerts := make([]SecurityAlert, len(ss.alerts))
|
||||
copy(alerts, ss.alerts)
|
||||
ss.mu.Unlock()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, alert := range alerts {
|
||||
seen[securityAlertKey(alert)] = true
|
||||
}
|
||||
|
||||
for i, log := range config.AppConfig.AuditLogs {
|
||||
alert, ok := alertFromSecurityAuditLog(log, i)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := securityAlertKey(alert)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
|
||||
sort.SliceStable(alerts, func(i, j int) bool {
|
||||
ti, errI := time.Parse("2006-01-02 15:04:05", alerts[i].Timestamp)
|
||||
tj, errJ := time.Parse("2006-01-02 15:04:05", alerts[j].Timestamp)
|
||||
if errI == nil && errJ == nil && !ti.Equal(tj) {
|
||||
return ti.After(tj)
|
||||
}
|
||||
return alerts[i].Timestamp > alerts[j].Timestamp
|
||||
})
|
||||
|
||||
if len(alerts) > 200 {
|
||||
alerts = alerts[:200]
|
||||
}
|
||||
if alerts == nil {
|
||||
return []SecurityAlert{}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func securityAlertKey(alert SecurityAlert) string {
|
||||
return strings.Join([]string{
|
||||
alert.Timestamp,
|
||||
alert.ContainerName,
|
||||
alert.Type,
|
||||
alert.Detail,
|
||||
strconv.Itoa(alert.TargetPort),
|
||||
}, "\x1f")
|
||||
}
|
||||
|
||||
func alertFromSecurityAuditLog(log config.AuditLog, index int) (SecurityAlert, bool) {
|
||||
if !strings.HasPrefix(log.Action, "security_") || log.Action == "security_auto_shutdown" || log.Action == "security_policy_unblock" {
|
||||
return SecurityAlert{}, false
|
||||
}
|
||||
alertType := strings.TrimPrefix(log.Action, "security_")
|
||||
severity, detail := parseSecurityAuditDetail(log.Detail)
|
||||
targetPort := parseDetailPort(detail)
|
||||
|
||||
targetIP := ""
|
||||
if targetPort > 0 || alertType == "horizontal_scan" || alertType == "brute_force" {
|
||||
targetIP = "*"
|
||||
}
|
||||
|
||||
return SecurityAlert{
|
||||
ID: fmt.Sprintf("audit-security-%d", index),
|
||||
ContainerName: log.Target,
|
||||
Type: alertType,
|
||||
Severity: severity,
|
||||
SourceIP: "",
|
||||
TargetIP: targetIP,
|
||||
TargetPort: targetPort,
|
||||
Detail: detail,
|
||||
LogLine: "",
|
||||
Timestamp: log.Time,
|
||||
Count: 1,
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseSecurityAuditDetail(detail string) (string, string) {
|
||||
severity := "medium"
|
||||
if strings.HasPrefix(detail, "[") {
|
||||
if end := strings.Index(detail, "]"); end > 1 {
|
||||
severity = detail[1:end]
|
||||
detail = strings.TrimSpace(detail[end+1:])
|
||||
}
|
||||
}
|
||||
return severity, detail
|
||||
}
|
||||
|
||||
func parseDetailPort(detail string) int {
|
||||
for _, marker := range []string{"端口 ", "端口"} {
|
||||
idx := strings.Index(detail, marker)
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
start := idx + len(marker)
|
||||
for start < len(detail) && (detail[start] == ' ' || detail[start] == ':' || detail[start] == '(') {
|
||||
start++
|
||||
}
|
||||
end := start
|
||||
for end < len(detail) && detail[end] >= '0' && detail[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end > start {
|
||||
port, _ := strconv.Atoi(detail[start:end])
|
||||
return port
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func HandleSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...)
|
||||
sortSnapshotsNewestFirst(snapshots)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots})
|
||||
}
|
||||
|
||||
func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) {
|
||||
switch {
|
||||
case action == "snapshots" && r.Method == http.MethodGet:
|
||||
listContainerSnapshots(w, r, containerID)
|
||||
case action == "snapshots" && r.Method == http.MethodPost:
|
||||
createContainerSnapshot(w, r, containerID)
|
||||
case action == "snapshots/schedule" && r.Method == http.MethodPost:
|
||||
updateSnapshotSchedule(w, r, containerID)
|
||||
case action == "snapshots/quota" && r.Method == http.MethodPut:
|
||||
updateSnapshotQuota(w, r, containerID)
|
||||
case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost:
|
||||
snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore")
|
||||
restoreContainerSnapshot(w, r, containerID, snapshotID)
|
||||
case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete:
|
||||
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||
deleteContainerSnapshot(w, r, containerID, snapshotID)
|
||||
default:
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot action not found"})
|
||||
}
|
||||
}
|
||||
|
||||
func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
c := config.FindContainer(containerID)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
snapshots := config.ContainerSnapshots(containerID)
|
||||
sortSnapshotsNewestFirst(snapshots)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"snapshots": snapshots,
|
||||
"quota": config.ContainerSnapshotLimit(c),
|
||||
"schedule": map[string]interface{}{
|
||||
"enabled": c.SnapshotScheduleEnabled,
|
||||
"interval_hours": c.SnapshotScheduleIntervalHours,
|
||||
"time": c.SnapshotScheduleTime,
|
||||
"last_run": c.SnapshotScheduleLastRun,
|
||||
"next_run": c.SnapshotScheduleNextRun,
|
||||
"created_by": c.SnapshotScheduleCreatedBy,
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
user := requestUser(r)
|
||||
if isSubUserRequest(r) {
|
||||
c := config.FindContainer(containerID)
|
||||
limit := config.ContainerSnapshotLimit(c)
|
||||
if len(config.ContainerSnapshots(containerID)) >= limit {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Snapshot quota reached. Delete an old snapshot first."})
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AddAuditLog("snapshot.create", snapshot.ContainerName, snapshot.ID, user)
|
||||
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: snapshot})
|
||||
}
|
||||
|
||||
func updateSnapshotQuota(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
if isSubUserRequest(r) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot change snapshot quota"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.SnapshotLimit <= 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot quota must be at least 1"})
|
||||
return
|
||||
}
|
||||
c := config.FindContainer(containerID)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
c.SnapshotLimit = req.SnapshotLimit
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"})
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
config.AddAuditLog("snapshot.quota", c.Name, "limit="+strconv.Itoa(req.SnapshotLimit), user)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"container": c,
|
||||
"quota": c.SnapshotLimit,
|
||||
}})
|
||||
}
|
||||
|
||||
func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalHours int `json:"interval_hours"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.IntervalHours <= 0 {
|
||||
req.IntervalHours = 24
|
||||
}
|
||||
if req.IntervalHours < 24 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot schedule interval cannot be less than 24 hours"})
|
||||
return
|
||||
}
|
||||
if req.Time == "" {
|
||||
req.Time = "03:00"
|
||||
}
|
||||
user := requestUser(r)
|
||||
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
config.AddAuditLog("snapshot.schedule", c.Name, "enabled", user)
|
||||
} else {
|
||||
config.AddAuditLog("snapshot.schedule", c.Name, "disabled", user)
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"container": c,
|
||||
}})
|
||||
}
|
||||
|
||||
func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot == nil || snapshot.ContainerID != containerID {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
if err := deleteSnapshotByRuntime(snapshotID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AddAuditLog("snapshot.delete", snapshot.ContainerName, snapshot.ID, user)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot deleted"})
|
||||
}
|
||||
|
||||
func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) {
|
||||
snapshot := config.FindSnapshot(snapshotID)
|
||||
if snapshot == nil || snapshot.ContainerID != containerID {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
|
||||
return
|
||||
}
|
||||
user := requestUser(r)
|
||||
if err := restoreSnapshotByRuntime(snapshotID); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AddAuditLog("snapshot.restore", snapshot.ContainerName, snapshot.ID, user)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot restored"})
|
||||
}
|
||||
|
||||
func requestUser(r *http.Request) string {
|
||||
if claims, ok := claimsFromRequest(r); ok {
|
||||
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||
return "user:" + subUser
|
||||
}
|
||||
if username, _ := claims["username"].(string); username != "" {
|
||||
return username
|
||||
}
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func sortSnapshotsNewestFirst(snapshots []config.Snapshot) {
|
||||
sort.SliceStable(snapshots, func(i, j int) bool {
|
||||
ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
|
||||
tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
|
||||
return tj.Before(ti)
|
||||
})
|
||||
}
|
||||
+122
-29
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -26,6 +27,7 @@ type terminalResizeMessage struct {
|
||||
|
||||
type webSSHTicket struct {
|
||||
ContainerName string
|
||||
SubUser bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -51,16 +53,22 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
if config.FindContainerByName(req.ContainerName) == nil {
|
||||
c := config.FindContainerByName(req.ContainerName)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) && c.PolicyBlocked {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||
return
|
||||
}
|
||||
|
||||
ticket := randomHex(32)
|
||||
webSSHTickets.Lock()
|
||||
cleanupExpiredWebSSHTicketsLocked(time.Now())
|
||||
webSSHTickets.items[ticket] = webSSHTicket{
|
||||
ContainerName: req.ContainerName,
|
||||
SubUser: isSubUserRequest(r),
|
||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||
}
|
||||
webSSHTickets.Unlock()
|
||||
@@ -73,7 +81,7 @@ func HandleWebSSHTicket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleWebSSH proxies an SSH session to the browser over WebSocket.
|
||||
func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
ticket := webSSHTicketFromRequest(r)
|
||||
if ticket == "" {
|
||||
http.Error(w, "ticket required", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -85,7 +93,8 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !consumeWebSSHTicket(ticket, containerName) {
|
||||
item, ok := consumeWebSSHTicket(ticket, containerName)
|
||||
if !ok {
|
||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -95,26 +104,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "container not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if item.SubUser && c.PolicyBlocked {
|
||||
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c.Status != "running" {
|
||||
http.Error(w, "container is not running", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.IP == "" {
|
||||
if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil {
|
||||
var ip string
|
||||
var err error
|
||||
if c.IsKVM() {
|
||||
ip, err = kvmManager.GetContainerIP(c.VirshName())
|
||||
} else {
|
||||
ip, err = lxcManager.GetContainerIP(c.LxcName())
|
||||
}
|
||||
if err == nil {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
if c.IP == "" {
|
||||
if c.IP == "" && !c.IsKVM() {
|
||||
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
|
||||
c.IP = ip
|
||||
}
|
||||
}
|
||||
if c.IP == "" && c.IsKVM() {
|
||||
if err := kvmManager.EnsureSSH(c.ID); err == nil {
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.IP == "" {
|
||||
http.Error(w, "container ip is not available", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
responseHeader := http.Header{}
|
||||
if protocol := webSSHTicketProtocol(r); protocol != "" {
|
||||
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, responseHeader)
|
||||
if err != nil {
|
||||
log.Printf("WebSSH upgrade failed: %v", err)
|
||||
return
|
||||
@@ -122,6 +153,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
defer ws.Close()
|
||||
|
||||
if c.SSHPassword == "" {
|
||||
if c.IsKVM() {
|
||||
writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n")
|
||||
return
|
||||
}
|
||||
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if err := lxcManager.EnsureSSH(c.ID); err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
|
||||
@@ -141,7 +176,7 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(c.SSHPassword),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
HostKeyCallback: containerHostKeyCallback(c),
|
||||
Timeout: 4 * time.Second,
|
||||
}
|
||||
|
||||
@@ -149,25 +184,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
|
||||
client, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
addr = net.JoinHostPort(c.IP, "22")
|
||||
}
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||
sshConfig.Timeout = 10 * time.Second
|
||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||
return
|
||||
if c.IsKVM() {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n")
|
||||
if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
addr = net.JoinHostPort(c.IP, "22")
|
||||
}
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||
sshConfig.Timeout = 10 * time.Second
|
||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
|
||||
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
|
||||
return
|
||||
}
|
||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||
c = refreshed
|
||||
}
|
||||
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
|
||||
c.IP = ip
|
||||
config.SaveConfig()
|
||||
addr = net.JoinHostPort(c.IP, "22")
|
||||
}
|
||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||
sshConfig.Timeout = 10 * time.Second
|
||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
@@ -250,6 +308,41 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("WebSSH disconnected for container %s", containerName)
|
||||
}
|
||||
|
||||
func containerHostKeyCallback(c *config.Container) ssh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
sum := sha256.Sum256(key.Marshal())
|
||||
fingerprint := hex.EncodeToString(sum[:])
|
||||
if c.SSHHostKey != "" && c.SSHHostKey != fingerprint {
|
||||
return fmt.Errorf("container SSH host key mismatch")
|
||||
}
|
||||
if c.SSHHostKey == "" {
|
||||
c.SSHHostKey = fingerprint
|
||||
config.SaveConfig()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func webSSHTicketFromRequest(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol[len(prefix):]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func webSSHTicketProtocol(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
|
||||
@@ -278,17 +371,17 @@ func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
|
||||
}
|
||||
|
||||
func consumeWebSSHTicket(ticket, containerName string) bool {
|
||||
func consumeWebSSHTicket(ticket, containerName string) (webSSHTicket, bool) {
|
||||
now := time.Now()
|
||||
webSSHTickets.Lock()
|
||||
defer webSSHTickets.Unlock()
|
||||
cleanupExpiredWebSSHTicketsLocked(now)
|
||||
item, ok := webSSHTickets.items[ticket]
|
||||
if !ok {
|
||||
return false
|
||||
return webSSHTicket{}, false
|
||||
}
|
||||
delete(webSSHTickets.items, ticket)
|
||||
return item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
}
|
||||
|
||||
func cleanupExpiredWebSSHTicketsLocked(now time.Time) {
|
||||
|
||||
+289
-43
@@ -21,6 +21,28 @@ func generateRandomStr(length int) string {
|
||||
return hex.EncodeToString(b)[:length]
|
||||
}
|
||||
|
||||
type subUserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
||||
return subUserResponse{
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
Password: password,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AccessCode: su.AccessCode,
|
||||
CreatedAt: su.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSubUserCreate creates a sub-user for a specific container
|
||||
func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -44,32 +66,36 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
containerName := c.Name
|
||||
|
||||
// Check if sub-user already exists for this container
|
||||
// Check if sub-user already exists and return the same management password.
|
||||
for i := range config.AppConfig.SubUsers {
|
||||
su := &config.AppConfig.SubUsers[i]
|
||||
for _, cn := range su.ContainerNames {
|
||||
if cn == containerName {
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if uuid == c.UUID {
|
||||
if su.AccessCode == "" {
|
||||
su.AccessCode = generateRandomStr(8)
|
||||
}
|
||||
if su.PassHash == "" && su.Password != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
password := su.Password
|
||||
message := "Sub-user link returned"
|
||||
if password == "" {
|
||||
password = generateRandomStr(16)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||
return
|
||||
}
|
||||
su.PassHash = string(hash)
|
||||
su.Password = password
|
||||
su.Token = ""
|
||||
su.TokenVersion++
|
||||
message = "Sub-user password generated"
|
||||
}
|
||||
if su.Password == "" {
|
||||
su.Password = generateRandomStr(16)
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
}
|
||||
}
|
||||
su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||
config.SaveConfig()
|
||||
// Return existing
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Sub-user already exists",
|
||||
Data: *su,
|
||||
Message: message,
|
||||
Data: newSubUserResponse(*su, password),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -84,16 +110,13 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// Generate short access code (8 chars, for URL sharing)
|
||||
accessCode := generateRandomStr(8)
|
||||
|
||||
// Generate JWT for sub-user
|
||||
tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
|
||||
|
||||
subUser := config.SubUser{
|
||||
ID: "sub-" + generateRandomStr(8),
|
||||
Username: username,
|
||||
Password: password,
|
||||
PassHash: string(hash),
|
||||
ContainerNames: []string{containerName},
|
||||
Token: tokenStr,
|
||||
ContainerUUIDs: []string{c.UUID},
|
||||
AccessCode: accessCode,
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -102,7 +125,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
config.SaveConfig()
|
||||
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin")
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser})
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: newSubUserResponse(subUser, password)})
|
||||
}
|
||||
|
||||
// HandleSubUserLogin handles sub-user login
|
||||
@@ -121,17 +144,24 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := r.Header.Get("X-Forwarded-For")
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
clientUA := r.Header.Get("User-Agent")
|
||||
|
||||
// Find sub-user
|
||||
for _, su := range config.AppConfig.SubUsers {
|
||||
if su.Username == req.Username {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
|
||||
// Generate fresh token
|
||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
||||
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||
if len(containerUUIDs) == 0 {
|
||||
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
|
||||
return
|
||||
}
|
||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
|
||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
|
||||
config.AddLoginLog(su.Username, clientIP, clientUA, true)
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
@@ -142,6 +172,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
})
|
||||
return
|
||||
} else {
|
||||
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,19 +198,28 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Find sub-user by access code
|
||||
clientIP := r.Header.Get("X-Forwarded-For")
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
clientUA := r.Header.Get("User-Agent")
|
||||
|
||||
for _, su := range config.AppConfig.SubUsers {
|
||||
if su.AccessCode == req.Code {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
|
||||
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
|
||||
return
|
||||
}
|
||||
|
||||
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
|
||||
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||
if len(containerUUIDs) == 0 {
|
||||
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
|
||||
return
|
||||
}
|
||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
|
||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
|
||||
config.AddLoginLog(su.Username, clientIP, clientUA, true)
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
@@ -195,10 +236,11 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
|
||||
}
|
||||
|
||||
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string {
|
||||
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time, tokenVersion int) string {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub_user": username,
|
||||
"container_uuids": containerUUIDs,
|
||||
"token_version": tokenVersion,
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
@@ -224,18 +266,6 @@ func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||
names: make(map[string]bool),
|
||||
uuids: make(map[string]bool),
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]interface{}); ok {
|
||||
for _, cn := range containerNames {
|
||||
if name, ok := cn.(string); ok {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if containerNames, ok := claims["container_names"].([]string); ok {
|
||||
for _, name := range containerNames {
|
||||
allowed.names[name] = true
|
||||
}
|
||||
}
|
||||
if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok {
|
||||
for _, item := range containerUUIDs {
|
||||
if uuid, ok := item.(string); ok {
|
||||
@@ -322,7 +352,11 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
action = strings.Join(parts[1:], "/")
|
||||
}
|
||||
if c.PolicyBlocked && isSubUserBlockedAction(action, r.Method) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||
return
|
||||
}
|
||||
if !isSubUserContainerActionAllowed(action, r.Method) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
|
||||
@@ -359,15 +393,46 @@ func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
|
||||
}
|
||||
filtered := make([]*Task, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) {
|
||||
if c := config.FindContainer(task.ContainerID); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
if task.ContainerName != "" {
|
||||
if c := config.FindContainerByName(task.ContainerName); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if task.Config.Name != "" {
|
||||
if c := config.FindContainerByName(task.Config.Name); c != nil && isContainerAllowed(allowed, c) {
|
||||
filtered = append(filtered, task)
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
|
||||
return allowed.names[c.Name] || (c.UUID != "" && allowed.uuids[c.UUID])
|
||||
return c != nil && c.UUID != "" && allowed.uuids[c.UUID]
|
||||
}
|
||||
|
||||
func isSubUserBlockedAction(action string, method string) bool {
|
||||
if action == "" {
|
||||
return method != http.MethodGet
|
||||
}
|
||||
switch action {
|
||||
case "usage", "traffic":
|
||||
return method != http.MethodGet
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func policyBlockedMessage(c *config.Container) string {
|
||||
if c != nil && c.PolicyBlockedReason != "" {
|
||||
return "虚拟机被策略临时封禁:" + c.PolicyBlockedReason
|
||||
}
|
||||
return "虚拟机被策略临时封禁"
|
||||
}
|
||||
|
||||
func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
@@ -377,6 +442,12 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
switch {
|
||||
case action == "usage" || action == "traffic" || action == "random-port":
|
||||
return method == http.MethodGet
|
||||
case action == "snapshots":
|
||||
return method == http.MethodGet || method == http.MethodPost
|
||||
case action == "snapshots/schedule":
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "snapshots/"):
|
||||
return method == http.MethodDelete || method == http.MethodPost
|
||||
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "port-mappings/"):
|
||||
@@ -386,16 +457,38 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func activeSubUserContainerUUIDs(su *config.SubUser) []string {
|
||||
uuids := make([]string, 0, len(su.ContainerUUIDs))
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||
uuids = appendUniqueString(uuids, c.UUID)
|
||||
}
|
||||
}
|
||||
if len(uuids) > 0 {
|
||||
return uuids
|
||||
}
|
||||
return subUserContainerUUIDs(su.ContainerNames)
|
||||
}
|
||||
|
||||
func subUserContainerUUIDs(containerNames []string) []string {
|
||||
uuids := make([]string, 0, len(containerNames))
|
||||
for _, name := range containerNames {
|
||||
if c := config.FindContainerByName(name); c != nil && c.UUID != "" {
|
||||
uuids = append(uuids, c.UUID)
|
||||
uuids = appendUniqueString(uuids, c.UUID)
|
||||
}
|
||||
}
|
||||
return uuids
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func splitPath(path string) []string {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range splitBy(path, "/") {
|
||||
@@ -420,3 +513,156 @@ func splitBy(s, sep string) []string {
|
||||
result = append(result, current)
|
||||
return result
|
||||
}
|
||||
|
||||
// SubUserListItem is the enriched sub-user info returned by the list API
|
||||
type SubUserListItem struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids"`
|
||||
ContainerName string `json:"container_name"`
|
||||
ContainerUUID string `json:"container_uuid"`
|
||||
AccessCode string `json:"access_code"`
|
||||
Password string `json:"password,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastLogin string `json:"last_login"`
|
||||
LastLoginIP string `json:"last_login_ip"`
|
||||
LastLoginUA string `json:"last_login_ua"`
|
||||
}
|
||||
|
||||
// HandleSubUserList returns the list of all sub-users with container info
|
||||
func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||
for _, su := range config.AppConfig.SubUsers {
|
||||
item := SubUserListItem{
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AccessCode: su.AccessCode,
|
||||
Password: su.Password,
|
||||
CreatedAt: su.CreatedAt,
|
||||
}
|
||||
|
||||
// Resolve container name from first active UUID
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||
item.ContainerName = c.Name
|
||||
item.ContainerUUID = c.UUID
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ContainerName == "" && len(su.ContainerNames) > 0 {
|
||||
item.ContainerName = su.ContainerNames[0]
|
||||
}
|
||||
|
||||
// Find last login time
|
||||
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
|
||||
log := config.AppConfig.LoginLogs[i]
|
||||
if log.Username == su.Username {
|
||||
item.LastLogin = log.Time
|
||||
item.LastLoginIP = log.IP
|
||||
item.LastLoginUA = log.UserAgent
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Skip orphaned sub-users with no active containers
|
||||
if item.ContainerName == "" && item.ContainerUUID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||
}
|
||||
|
||||
// HandleSubUserAction handles actions on a specific sub-user
|
||||
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
subUserID := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
// Find sub-user
|
||||
var target *config.SubUser
|
||||
for i := range config.AppConfig.SubUsers {
|
||||
if config.AppConfig.SubUsers[i].ID == subUserID {
|
||||
target = &config.AppConfig.SubUsers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Sub-user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case action == "rotate-password" && r.Method == http.MethodPost:
|
||||
password := generateRandomStr(16)
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||
target.PassHash = string(hash)
|
||||
target.Password = password
|
||||
target.Token = ""
|
||||
target.TokenVersion++ // invalidate all existing tokens
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"password": password,
|
||||
"access_code": target.AccessCode,
|
||||
"username": target.Username,
|
||||
}})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||
|
||||
case action == "audit-logs" && r.Method == http.MethodGet:
|
||||
// Filter audit logs for this sub-user
|
||||
logs := filterSubUserAuditLogs(target.Username)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||
|
||||
case action == "login-logs" && r.Method == http.MethodGet:
|
||||
// Filter login logs for this sub-user
|
||||
logs := filterSubUserLoginLogs(target.Username)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||
|
||||
default:
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||
}
|
||||
}
|
||||
|
||||
func filterSubUserAuditLogs(username string) []config.AuditLog {
|
||||
result := make([]config.AuditLog, 0)
|
||||
for i := len(config.AppConfig.AuditLogs) - 1; i >= 0; i-- {
|
||||
log := config.AppConfig.AuditLogs[i]
|
||||
if log.User == username || strings.HasPrefix(log.User, "user:") && strings.Contains(log.User, username) {
|
||||
result = append(result, log)
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = []config.AuditLog{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func filterSubUserLoginLogs(username string) []config.SavedLoginLog {
|
||||
result := make([]config.SavedLoginLog, 0)
|
||||
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
|
||||
log := config.AppConfig.LoginLogs[i]
|
||||
if log.Username == username {
|
||||
result = append(result, log)
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = []config.SavedLoginLog{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ type Task struct {
|
||||
Config lxc.ContainerConfig `json:"config,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
User string `json:"user,omitempty"` // who created this task
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
type TaskQueue struct {
|
||||
@@ -100,6 +102,10 @@ func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
|
||||
return q.EnqueueBatchWithAudit(taskType, ids, templateID, user, "", "")
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, templateID string, user string, ip string, userAgent string) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
var result []string
|
||||
@@ -109,7 +115,7 @@ func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateI
|
||||
if c != nil {
|
||||
name = c.Name
|
||||
}
|
||||
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user))
|
||||
result = append(result, q.enqueueSingleWithAudit(id, name, taskType, templateID, user, ip, userAgent))
|
||||
}
|
||||
q.persistTasks()
|
||||
return result
|
||||
@@ -168,6 +174,10 @@ func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskTyp
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
|
||||
return q.enqueueSingleWithAudit(containerID, containerName, taskType, templateID, user, "", "")
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string, taskType TaskType, templateID string, user string, ip string, userAgent string) string {
|
||||
id := q.nextID
|
||||
q.nextID++
|
||||
task := &Task{
|
||||
@@ -179,11 +189,31 @@ func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string,
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
q.enqueueTask(task)
|
||||
return task.ID
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
for _, task := range q.tasks {
|
||||
if task.Type != TaskStop || task.ContainerID != containerID {
|
||||
continue
|
||||
}
|
||||
if task.Status == "pending" || task.Status == "running" {
|
||||
return task.ID, false
|
||||
}
|
||||
}
|
||||
|
||||
taskID := q.enqueueSingleWithAudit(containerID, containerName, TaskStop, "", "system:security", "", "")
|
||||
q.persistTasks()
|
||||
return taskID, true
|
||||
}
|
||||
|
||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
||||
// If a restored task already has a same-name container in config, it resumes
|
||||
// initialization instead of creating another ct-{id}.
|
||||
@@ -214,7 +244,7 @@ func (q *TaskQueue) createWorker() {
|
||||
c := config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
// 1) Download image + apply limits (lxc-create)
|
||||
err := lxcManager.CreateContainer(task.Config)
|
||||
err := createByRuntime(task.Config)
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
@@ -244,10 +274,10 @@ func (q *TaskQueue) createWorker() {
|
||||
|
||||
// 3) Start + initialize SSH/network in the same worker.
|
||||
// If init fails, destroy the container so no dead entry remains.
|
||||
startErr := lxcManager.StartContainer(c.ID)
|
||||
startErr := startByRuntime(c.ID)
|
||||
if startErr != nil {
|
||||
if createdByTask {
|
||||
lxcManager.DestroyContainer(c.ID)
|
||||
_ = destroyByRuntime(c.ID)
|
||||
}
|
||||
task.Status = "failed"
|
||||
task.Error = startErr.Error()
|
||||
@@ -292,13 +322,13 @@ func (q *TaskQueue) opWorker() {
|
||||
if err == nil {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = lxcManager.StartContainer(task.ContainerID)
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = lxcManager.StopContainer(task.ContainerID)
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = lxcManager.RestartContainer(task.ContainerID)
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = lxcManager.DestroyContainer(task.ContainerID)
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
@@ -306,7 +336,7 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,17 +348,21 @@ func (q *TaskQueue) opWorker() {
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser)
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser)
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskStop:
|
||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||
case TaskRestart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskReinstall:
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
}
|
||||
}
|
||||
q.persistTasks()
|
||||
@@ -336,6 +370,17 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
func clearPolicyBlockAfterAdminRecovery(task *Task) {
|
||||
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
|
||||
return
|
||||
}
|
||||
c := config.FindContainer(task.ContainerID)
|
||||
if c != nil && c.PolicyBlocked {
|
||||
config.SetContainerPolicyBlock(c.ID, false, "")
|
||||
config.AddAuditLog("security_policy_unblock", c.Name, "管理员操作后解除策略临时封禁", task.User)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTaskContainer(task *Task) error {
|
||||
if task.Type == TaskCreate {
|
||||
return nil
|
||||
@@ -418,6 +463,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
user = "user:" + subUser
|
||||
}
|
||||
}
|
||||
ip := clientIP(r)
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
|
||||
var taskType TaskType
|
||||
var templateID string
|
||||
@@ -442,13 +489,21 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
templateID = c.Template
|
||||
}
|
||||
}
|
||||
runtime := runtimeFromTemplateID(templateID)
|
||||
if c := config.FindContainer(id); c != nil {
|
||||
runtime = c.Runtime()
|
||||
}
|
||||
if !isImageEnabledAndDownloaded(templateID, runtime) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
return
|
||||
}
|
||||
|
||||
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user)
|
||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{
|
||||
Success: true,
|
||||
Message: "Task queued",
|
||||
@@ -498,13 +553,24 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Containers[i].VCPU <= 0 {
|
||||
req.Containers[i].VCPU = 1
|
||||
}
|
||||
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
|
||||
if req.Containers[i].RAMMB < 128 {
|
||||
req.Containers[i].RAMMB = 512
|
||||
}
|
||||
if req.Containers[i].DiskGB < 1 {
|
||||
req.Containers[i].DiskGB = 5
|
||||
}
|
||||
if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
||||
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].PortMappingCount < 2 {
|
||||
req.Containers[i].PortMappingCount = 2
|
||||
}
|
||||
if req.Containers[i].SnapshotLimit <= 0 {
|
||||
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
if err := validateRuntimeResourceRequest(req.Containers[i].Virtualization, req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type webVNCTicket struct {
|
||||
ContainerName string
|
||||
ContainerUUID string
|
||||
SubUser bool
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var webVNCTickets = struct {
|
||||
sync.Mutex
|
||||
items map[string]webVNCTicket
|
||||
}{items: map[string]webVNCTicket{}}
|
||||
|
||||
func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
|
||||
return
|
||||
}
|
||||
if !isContainerAllowedForRequest(r, req.ContainerName) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
c := config.FindContainerByName(req.ContainerName)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) && c.PolicyBlocked {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: policyBlockedMessage(c)})
|
||||
return
|
||||
}
|
||||
if !c.IsKVM() {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
|
||||
return
|
||||
}
|
||||
|
||||
ticket := randomHex(32)
|
||||
webVNCTickets.Lock()
|
||||
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||
webVNCTickets.items[ticket] = webVNCTicket{
|
||||
ContainerName: c.Name,
|
||||
ContainerUUID: c.UUID,
|
||||
SubUser: isSubUserRequest(r),
|
||||
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||
}
|
||||
webVNCTickets.Unlock()
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"ticket": ticket},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleVNCProxy proxies a KVM VM's local libvirt VNC socket to the browser.
|
||||
func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := webVNCTicketFromRequest(r)
|
||||
if ticket == "" {
|
||||
http.Error(w, "ticket required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
containerName := r.URL.Query().Get("container")
|
||||
if containerName == "" {
|
||||
http.Error(w, "container name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
item, ok := consumeWebVNCTicket(ticket, containerName)
|
||||
if !ok {
|
||||
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil || c.UUID != item.ContainerUUID {
|
||||
http.Error(w, "container not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if item.SubUser && c.PolicyBlocked {
|
||||
http.Error(w, "虚拟机被策略临时封禁", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !c.IsKVM() {
|
||||
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Status != "running" {
|
||||
http.Error(w, "container is not running", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
vncPort, err := kvmManager.RefreshVNCPort(c.ID)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("VNC display is not available: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
vncConn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", vncPort)), 5*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("VNC connection failed: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer vncConn.Close()
|
||||
|
||||
responseHeader := http.Header{}
|
||||
if protocol := webVNCResponseProtocol(r); protocol != "" {
|
||||
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
|
||||
}
|
||||
ws, err := upgrader.Upgrade(w, r, responseHeader)
|
||||
if err != nil {
|
||||
log.Printf("WebVNC upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
|
||||
|
||||
done := make(chan string, 2)
|
||||
var writeMu sync.Mutex
|
||||
go streamVNCToWebSocket(ws, &writeMu, vncConn, done)
|
||||
go streamWebSocketToVNC(ws, vncConn, done)
|
||||
|
||||
reason := <-done
|
||||
_ = vncConn.Close()
|
||||
_ = ws.Close()
|
||||
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
|
||||
}
|
||||
|
||||
func webVNCTicketFromRequest(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-vnc-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol[len(prefix):]
|
||||
}
|
||||
}
|
||||
return r.URL.Query().Get("ticket")
|
||||
}
|
||||
|
||||
func webVNCResponseProtocol(r *http.Request) string {
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
if protocol == "binary" {
|
||||
return protocol
|
||||
}
|
||||
}
|
||||
for _, protocol := range websocket.Subprotocols(r) {
|
||||
const prefix = "clicd-vnc-ticket."
|
||||
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||
return protocol
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
||||
now := time.Now()
|
||||
webVNCTickets.Lock()
|
||||
defer webVNCTickets.Unlock()
|
||||
cleanupExpiredWebVNCTicketsLocked(now)
|
||||
item, ok := webVNCTickets.items[ticket]
|
||||
if !ok {
|
||||
return webVNCTicket{}, false
|
||||
}
|
||||
delete(webVNCTickets.items, ticket)
|
||||
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||
}
|
||||
|
||||
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||
for ticket, item := range webVNCTickets.items {
|
||||
if !now.Before(item.ExpiresAt) {
|
||||
delete(webVNCTickets.items, ticket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamVNCToWebSocket(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- string) {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
writeMu.Lock()
|
||||
writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n])
|
||||
writeMu.Unlock()
|
||||
if writeErr != nil {
|
||||
done <- fmt.Sprintf("browser websocket write failed: %v", writeErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
done <- "VNC server closed connection"
|
||||
} else {
|
||||
done <- fmt.Sprintf("VNC server read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamWebSocketToVNC(ws *websocket.Conn, dst net.Conn, done chan<- string) {
|
||||
for {
|
||||
messageType, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
done <- fmt.Sprintf("browser websocket read failed: %v", err)
|
||||
return
|
||||
}
|
||||
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
if _, err := dst.Write(msg); err != nil {
|
||||
done <- fmt.Sprintf("VNC server write failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+681
-130
File diff suppressed because it is too large
Load Diff
+459
-131
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -47,53 +46,92 @@ type SavedLoginLog struct {
|
||||
|
||||
// AuditLog represents an operation log entry
|
||||
type AuditLog struct {
|
||||
Time string `json:"time"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Detail string `json:"detail"`
|
||||
User string `json:"user"`
|
||||
Time string `json:"time"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Detail string `json:"detail"`
|
||||
User string `json:"user"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
Success *bool `json:"success,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// OversellConfig controls host-level overselling behavior
|
||||
type OversellConfig struct {
|
||||
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
|
||||
RAMOvercommit int `json:"ram_overcommit"` // multiplier
|
||||
DiskOvercommit int `json:"disk_overcommit"` // multiplier
|
||||
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
|
||||
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
|
||||
type VMReadinessCheck struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Container represents an LXC container configuration
|
||||
type Container struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
LXCName string `json:"lxc_name,omitempty"`
|
||||
Template string `json:"template"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
|
||||
TrafficUsedRX int64 `json:"traffic_used_rx"`
|
||||
TrafficUsedTX int64 `json:"traffic_used_tx"`
|
||||
TrafficResetDate string `json:"traffic_reset_date"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||
IPv6Interface string `json:"ipv6_interface"`
|
||||
VNCPort int `json:"vnc_port"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHPassword string `json:"ssh_password"`
|
||||
PortMappings []PortMapping `json:"port_mappings"`
|
||||
PortMappingLimit int `json:"port_mapping_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
LXCName string `json:"lxc_name,omitempty"`
|
||||
KVMName string `json:"kvm_name,omitempty"`
|
||||
DiskImage string `json:"disk_image,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
Template string `json:"template"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
|
||||
TrafficUsedRX int64 `json:"traffic_used_rx"`
|
||||
TrafficUsedTX int64 `json:"traffic_used_tx"`
|
||||
TrafficResetDate string `json:"traffic_reset_date"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||
IPv6Interface string `json:"ipv6_interface"`
|
||||
VNCPort int `json:"vnc_port"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHPassword string `json:"ssh_password"`
|
||||
SSHHostKey string `json:"ssh_host_key,omitempty"`
|
||||
PortMappings []PortMapping `json:"port_mappings"`
|
||||
PortMappingLimit int `json:"port_mapping_limit"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
|
||||
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
|
||||
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
|
||||
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
||||
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
||||
PolicyBlocked bool `json:"policy_blocked"`
|
||||
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
|
||||
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
VirtualizationLXC = "lxc"
|
||||
VirtualizationKVM = "kvm"
|
||||
)
|
||||
|
||||
func NormalizeVirtualization(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case VirtualizationKVM:
|
||||
return VirtualizationKVM
|
||||
default:
|
||||
return VirtualizationLXC
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Container) Runtime() string {
|
||||
return NormalizeVirtualization(c.Virtualization)
|
||||
}
|
||||
|
||||
func (c *Container) IsKVM() bool {
|
||||
return c.Runtime() == VirtualizationKVM
|
||||
}
|
||||
|
||||
// LxcName returns the internal LXC container name (ct-{id})
|
||||
@@ -104,6 +142,14 @@ func (c *Container) LxcName() string {
|
||||
return fmt.Sprintf("ct-%d", c.ID)
|
||||
}
|
||||
|
||||
// VirshName returns the internal libvirt domain name for KVM instances.
|
||||
func (c *Container) VirshName() string {
|
||||
if c.KVMName != "" {
|
||||
return c.KVMName
|
||||
}
|
||||
return fmt.Sprintf("vm-%d", c.ID)
|
||||
}
|
||||
|
||||
// SubUser represents a sub-user with access to specific containers
|
||||
type ApiKeyConfig struct {
|
||||
ID string `json:"id"`
|
||||
@@ -130,38 +176,55 @@ func DeleteApiKey(id string) {
|
||||
type SubUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"` // plaintext for display
|
||||
Password string `json:"password,omitempty"`
|
||||
PassHash string `json:"pass_hash"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
Token string `json:"token"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
Token string `json:"-"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TokenVersion int `json:"token_version"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"`
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Scheduled bool `json:"scheduled"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// ClicdConfig is the main configuration structure
|
||||
type ClicdConfig struct {
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
Oversell OversellConfig `json:"oversell"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
}
|
||||
|
||||
var configPath string
|
||||
var AppConfig *ClicdConfig
|
||||
|
||||
const DefaultSnapshotLimit = 3
|
||||
|
||||
func getConfigPath() string {
|
||||
if configPath != "" {
|
||||
return configPath
|
||||
@@ -217,75 +280,91 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
dataDir := getDataDir()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %v", err)
|
||||
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
||||
}
|
||||
if err := openConfigDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
// First run: generate new config
|
||||
adminUser := "admin"
|
||||
adminPass := generateRandomString(16)
|
||||
jwtSecret := generateRandomString(32)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %v", err)
|
||||
cfg, ok, err := loadConfigFromDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
AppConfig = cfg
|
||||
normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Oversell: OversellConfig{
|
||||
CPUOvercommit: 4,
|
||||
RAMOvercommit: 1,
|
||||
DiskOvercommit: 2,
|
||||
KSMEnabled: true,
|
||||
Swappiness: 10,
|
||||
},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println(" CLICD - LXC Container Manager")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf(" Username: %s\n", adminUser)
|
||||
fmt.Printf(" Password: %s\n", adminPass)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" Please save these credentials!")
|
||||
fmt.Println(" Web Interface: http://0.0.0.0:8999")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
// Load existing config
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
legacy, ok, err := loadLegacyJSONConfig(cfgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
AppConfig = legacy
|
||||
normalizeConfigDefaults(dataDir)
|
||||
if migrateLoadedConfig() {
|
||||
// Save below persists normalized legacy data into SQLite.
|
||||
}
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{}
|
||||
if err := json.Unmarshal(data, AppConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %v", err)
|
||||
adminUser := "admin"
|
||||
adminPass := generateRandomString(16)
|
||||
jwtSecret := generateRandomString(32)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Snapshots: []Snapshot{},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println(" CLICD - LXC Container Manager")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf(" Username: %s\n", adminUser)
|
||||
fmt.Printf(" Password: %s\n", adminPass)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" Please save these credentials!")
|
||||
fmt.Println(" Web Interface: http://0.0.0.0:8999")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
return AppConfig, nil
|
||||
}
|
||||
|
||||
func normalizeConfigDefaults(dataDir string) {
|
||||
if AppConfig.Port == 0 {
|
||||
AppConfig.Port = 8999
|
||||
}
|
||||
@@ -304,20 +383,77 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
if AppConfig.Containers == nil {
|
||||
AppConfig.Containers = make([]Container, 0)
|
||||
}
|
||||
if AppConfig.Snapshots == nil {
|
||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
}
|
||||
if AppConfig.ApiKeys == nil {
|
||||
AppConfig.ApiKeys = make([]ApiKeyConfig, 0)
|
||||
}
|
||||
if AppConfig.AuditLogs == nil {
|
||||
AppConfig.AuditLogs = make([]AuditLog, 0)
|
||||
}
|
||||
if AppConfig.Tasks == nil {
|
||||
AppConfig.Tasks = make([]SavedTask, 0)
|
||||
}
|
||||
if AppConfig.LoginLogs == nil {
|
||||
AppConfig.LoginLogs = make([]SavedLoginLog, 0)
|
||||
}
|
||||
if AppConfig.EnabledImages == nil {
|
||||
AppConfig.EnabledImages = make([]string, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateLoadedConfig() bool {
|
||||
changed := ensureContainerUUIDs()
|
||||
if ensureContainerVirtualization() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerPortMappingLimits() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerSnapshotLimits() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerSnapshotScheduleDefaults() {
|
||||
changed = true
|
||||
}
|
||||
if migrateSubUsers() {
|
||||
changed = true
|
||||
}
|
||||
if removeLegacyVNCMappings() {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerVirtualization() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization)
|
||||
if AppConfig.Containers[i].Virtualization != next {
|
||||
AppConfig.Containers[i].Virtualization = next
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
return AppConfig, nil
|
||||
func ensureContainerSnapshotScheduleDefaults() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleIntervalHours < 24 {
|
||||
AppConfig.Containers[i].SnapshotScheduleIntervalHours = 24
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleTime == "" {
|
||||
AppConfig.Containers[i].SnapshotScheduleTime = "03:00"
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerUUIDs() bool {
|
||||
@@ -355,6 +491,68 @@ func ensureContainerPortMappingLimits() bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerSnapshotLimits() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
if AppConfig.Containers[i].SnapshotLimit <= 0 {
|
||||
AppConfig.Containers[i].SnapshotLimit = DefaultSnapshotLimit
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func migrateSubUsers() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.SubUsers {
|
||||
su := &AppConfig.SubUsers[i]
|
||||
if su.PassHash == "" && su.Password != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
|
||||
su.PassHash = string(hash)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if su.Token != "" {
|
||||
su.Token = ""
|
||||
changed = true
|
||||
}
|
||||
if len(su.ContainerUUIDs) == 0 && len(su.ContainerNames) > 0 {
|
||||
for _, name := range su.ContainerNames {
|
||||
if c := FindContainerByName(name); c != nil && c.UUID != "" {
|
||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||
}
|
||||
}
|
||||
if len(su.ContainerUUIDs) > 0 {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
func NormalizeSnapshotLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return DefaultSnapshotLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func ContainerSnapshotLimit(c *Container) int {
|
||||
if c == nil {
|
||||
return DefaultSnapshotLimit
|
||||
}
|
||||
return NormalizeSnapshotLimit(c.SnapshotLimit)
|
||||
}
|
||||
|
||||
func removeLegacyVNCMappings() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
@@ -379,11 +577,7 @@ func removeLegacyVNCMappings() bool {
|
||||
|
||||
// SaveConfig saves configuration to disk
|
||||
func SaveConfig() error {
|
||||
data, err := json.MarshalIndent(AppConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %v", err)
|
||||
}
|
||||
return os.WriteFile(getConfigPath(), data, 0600)
|
||||
return saveConfigToDB()
|
||||
}
|
||||
|
||||
// AddContainer adds a container to the config
|
||||
@@ -391,6 +585,7 @@ func AddContainer(c Container) {
|
||||
if c.UUID == "" {
|
||||
c.UUID = NewContainerUUID()
|
||||
}
|
||||
c.Virtualization = NormalizeVirtualization(c.Virtualization)
|
||||
AppConfig.Containers = append(AppConfig.Containers, c)
|
||||
SaveConfig()
|
||||
}
|
||||
@@ -407,7 +602,10 @@ func AllocateContainerID() int {
|
||||
func RemoveContainer(id int) bool {
|
||||
for i, c := range AppConfig.Containers {
|
||||
if c.ID == id {
|
||||
removeSubUserContainerAccess(c.Name)
|
||||
removeSubUserContainerAccess(c.Name, c.UUID)
|
||||
removeContainerSnapshotMetadata(id)
|
||||
// Clear snapshot schedule for this container
|
||||
clearContainerSnapshotSchedule(&AppConfig.Containers[i])
|
||||
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
|
||||
SaveConfig()
|
||||
return true
|
||||
@@ -416,8 +614,67 @@ func RemoveContainer(id int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func removeSubUserContainerAccess(containerName string) {
|
||||
if containerName == "" || len(AppConfig.SubUsers) == 0 {
|
||||
func clearContainerSnapshotSchedule(c *Container) {
|
||||
c.SnapshotScheduleEnabled = false
|
||||
c.SnapshotScheduleIntervalHours = 0
|
||||
c.SnapshotScheduleTime = ""
|
||||
c.SnapshotScheduleLastRun = ""
|
||||
c.SnapshotScheduleNextRun = ""
|
||||
c.SnapshotScheduleCreatedBy = ""
|
||||
}
|
||||
|
||||
func AddSnapshot(snapshot Snapshot) {
|
||||
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
func FindSnapshot(id string) *Snapshot {
|
||||
for i := range AppConfig.Snapshots {
|
||||
if AppConfig.Snapshots[i].ID == id {
|
||||
return &AppConfig.Snapshots[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveSnapshot(id string) bool {
|
||||
for i := range AppConfig.Snapshots {
|
||||
if AppConfig.Snapshots[i].ID == id {
|
||||
AppConfig.Snapshots = append(AppConfig.Snapshots[:i], AppConfig.Snapshots[i+1:]...)
|
||||
SaveConfig()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ContainerSnapshots(containerID int) []Snapshot {
|
||||
result := make([]Snapshot, 0)
|
||||
for _, snapshot := range AppConfig.Snapshots {
|
||||
if snapshot.ContainerID == containerID {
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeContainerSnapshotMetadata(containerID int) {
|
||||
filtered := make([]Snapshot, 0, len(AppConfig.Snapshots))
|
||||
for _, snapshot := range AppConfig.Snapshots {
|
||||
if snapshot.ContainerID != containerID {
|
||||
filtered = append(filtered, snapshot)
|
||||
}
|
||||
}
|
||||
AppConfig.Snapshots = filtered
|
||||
}
|
||||
|
||||
func RemoveSubUserContainerAccess(containerName string, containerUUID string) {
|
||||
removeSubUserContainerAccess(containerName, containerUUID)
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
func removeSubUserContainerAccess(containerName string, containerUUID string) {
|
||||
if containerName == "" && containerUUID == "" || len(AppConfig.SubUsers) == 0 {
|
||||
return
|
||||
}
|
||||
filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers))
|
||||
@@ -428,10 +685,17 @@ func removeSubUserContainerAccess(containerName string) {
|
||||
filteredNames = append(filteredNames, name)
|
||||
}
|
||||
}
|
||||
if len(filteredNames) == 0 {
|
||||
filteredUUIDs := make([]string, 0, len(su.ContainerUUIDs))
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if uuid != containerUUID {
|
||||
filteredUUIDs = append(filteredUUIDs, uuid)
|
||||
}
|
||||
}
|
||||
if len(filteredNames) == 0 && len(filteredUUIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
su.ContainerNames = filteredNames
|
||||
su.ContainerUUIDs = filteredUUIDs
|
||||
filteredUsers = append(filteredUsers, su)
|
||||
}
|
||||
AppConfig.SubUsers = filteredUsers
|
||||
@@ -489,20 +753,51 @@ func UpdateContainerStatus(id int, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
|
||||
c := FindContainer(id)
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.PolicyBlocked = blocked
|
||||
if blocked {
|
||||
c.PolicyBlockedReason = reason
|
||||
c.PolicyBlockedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
} else {
|
||||
c.PolicyBlockedReason = ""
|
||||
c.PolicyBlockedAt = ""
|
||||
}
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// UpdateVNC refreshes all container statuses
|
||||
func UpdateVNC(containers []Container) {
|
||||
AppConfig.Containers = containers
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() int {
|
||||
used := collectAllHostPorts()
|
||||
port := AppConfig.NextSSHPort
|
||||
AppConfig.NextSSHPort++
|
||||
for used[port] {
|
||||
port++
|
||||
}
|
||||
AppConfig.NextSSHPort = port + 1
|
||||
SaveConfig()
|
||||
return port
|
||||
}
|
||||
|
||||
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
|
||||
func collectAllHostPorts() map[int]bool {
|
||||
used := map[int]bool{}
|
||||
for _, c := range AppConfig.Containers {
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key)
|
||||
func IsValidContainerName(name string) bool {
|
||||
return IsValidContainerNameSyntax(name)
|
||||
@@ -538,6 +833,26 @@ func AddAuditLog(action, target, detail, user string) {
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) {
|
||||
s := success
|
||||
log := AuditLog{
|
||||
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Action: action,
|
||||
Target: target,
|
||||
Detail: detail,
|
||||
User: user,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Success: &s,
|
||||
Error: errMsg,
|
||||
}
|
||||
AppConfig.AuditLogs = append(AppConfig.AuditLogs, log)
|
||||
if len(AppConfig.AuditLogs) > 500 {
|
||||
AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:]
|
||||
}
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// SaveTasks persists the task queue to config
|
||||
func SaveTasks(tasks []SavedTask) {
|
||||
AppConfig.Tasks = tasks
|
||||
@@ -575,6 +890,19 @@ func CleanStaleContainers() {
|
||||
valid := make([]Container, 0)
|
||||
changed := false
|
||||
for _, c := range AppConfig.Containers {
|
||||
if c.IsKVM() {
|
||||
if c.DiskImage == "" {
|
||||
valid = append(valid, c)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) {
|
||||
fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName())
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
valid = append(valid, c)
|
||||
continue
|
||||
}
|
||||
lxcDir := "/var/lib/lxc/" + c.LxcName()
|
||||
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
|
||||
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
dbMu sync.Mutex
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
type savedTaskConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
if raw == "" {
|
||||
return savedTaskConfig{}
|
||||
}
|
||||
var cfg savedTaskConfig
|
||||
_ = json.Unmarshal([]byte(raw), &cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func getDBPath() string {
|
||||
cfgPath := getConfigPath()
|
||||
ext := filepath.Ext(cfgPath)
|
||||
if ext == "" {
|
||||
return cfgPath + ".db"
|
||||
}
|
||||
return strings.TrimSuffix(cfgPath, ext) + ".db"
|
||||
}
|
||||
|
||||
func openConfigDB() error {
|
||||
if db != nil {
|
||||
return nil
|
||||
}
|
||||
dbPath := getDBPath()
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create database directory: %v", err)
|
||||
}
|
||||
next, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open sqlite database: %v", err)
|
||||
}
|
||||
next.SetMaxOpenConns(1)
|
||||
next.SetMaxIdleConns(1)
|
||||
|
||||
for _, stmt := range []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
} {
|
||||
if _, err := next.Exec(stmt); err != nil {
|
||||
_ = next.Close()
|
||||
return fmt.Errorf("failed to initialize sqlite pragma: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
db = next
|
||||
return ensureSchema()
|
||||
}
|
||||
|
||||
func ensureSchema() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS containers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
virtualization TEXT,
|
||||
lxc_name TEXT,
|
||||
kvm_name TEXT,
|
||||
disk_image TEXT,
|
||||
mac_address TEXT,
|
||||
template TEXT,
|
||||
vcpu REAL,
|
||||
ram_mb INTEGER,
|
||||
disk_gb INTEGER,
|
||||
network_bw_mbps INTEGER,
|
||||
monthly_traffic_gb INTEGER,
|
||||
traffic_mode TEXT,
|
||||
traffic_in_gb INTEGER,
|
||||
traffic_out_gb INTEGER,
|
||||
traffic_used_rx INTEGER,
|
||||
traffic_used_tx INTEGER,
|
||||
traffic_reset_date TEXT,
|
||||
io_speed_mbps INTEGER,
|
||||
status TEXT,
|
||||
ip TEXT,
|
||||
ipv6 TEXT,
|
||||
ipv6_prefix_len INTEGER,
|
||||
ipv6_interface TEXT,
|
||||
vnc_port INTEGER,
|
||||
ssh_port INTEGER,
|
||||
ssh_password TEXT,
|
||||
ssh_host_key TEXT,
|
||||
port_mapping_limit INTEGER,
|
||||
snapshot_limit INTEGER,
|
||||
created_at TEXT,
|
||||
expires_at TEXT,
|
||||
snapshot_schedule_enabled INTEGER,
|
||||
snapshot_schedule_interval_hours INTEGER,
|
||||
snapshot_schedule_time TEXT,
|
||||
snapshot_schedule_last_run TEXT,
|
||||
snapshot_schedule_next_run TEXT,
|
||||
snapshot_schedule_created_by TEXT,
|
||||
policy_blocked INTEGER,
|
||||
policy_blocked_reason TEXT,
|
||||
policy_blocked_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS port_mappings (
|
||||
container_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
container_port INTEGER NOT NULL,
|
||||
host_port INTEGER NOT NULL,
|
||||
protocol TEXT,
|
||||
description TEXT,
|
||||
PRIMARY KEY (container_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
pass_hash TEXT,
|
||||
access_code TEXT,
|
||||
created_at TEXT,
|
||||
token_version INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
|
||||
sub_user_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
container_name TEXT NOT NULL,
|
||||
PRIMARY KEY (sub_user_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_user_container_uuids (
|
||||
sub_user_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
container_uuid TEXT NOT NULL,
|
||||
PRIMARY KEY (sub_user_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
key_hash TEXT,
|
||||
prefix TEXT,
|
||||
ip_whitelist TEXT,
|
||||
created_at TEXT,
|
||||
last_used TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT,
|
||||
action TEXT,
|
||||
target TEXT,
|
||||
detail TEXT,
|
||||
user TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
success_set INTEGER,
|
||||
success INTEGER,
|
||||
error TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT,
|
||||
container_id INTEGER,
|
||||
container_name TEXT,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT,
|
||||
template_id TEXT,
|
||||
user TEXT,
|
||||
cfg_name TEXT,
|
||||
cfg_virtualization TEXT,
|
||||
cfg_template_id TEXT,
|
||||
cfg_vcpu REAL,
|
||||
cfg_cpu_percent INTEGER,
|
||||
cfg_ram_mb INTEGER,
|
||||
cfg_disk_gb INTEGER,
|
||||
cfg_network_bw_mbps INTEGER,
|
||||
cfg_monthly_traffic_gb INTEGER,
|
||||
cfg_traffic_mode TEXT,
|
||||
cfg_traffic_in_gb INTEGER,
|
||||
cfg_traffic_out_gb INTEGER,
|
||||
cfg_io_speed_mbps INTEGER,
|
||||
cfg_port_mapping_count INTEGER,
|
||||
cfg_snapshot_limit INTEGER,
|
||||
cfg_assign_ipv6 INTEGER,
|
||||
cfg_expires_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||
task_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
PRIMARY KEY (task_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS login_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT,
|
||||
username TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS enabled_images (
|
||||
position INTEGER PRIMARY KEY,
|
||||
image_id TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
container_id INTEGER,
|
||||
container_name TEXT,
|
||||
lxc_name TEXT,
|
||||
created_at TEXT,
|
||||
created_by TEXT,
|
||||
scheduled INTEGER,
|
||||
path TEXT,
|
||||
size_bytes INTEGER
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("failed to create sqlite schema: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
meta := map[string]string{}
|
||||
rows, err := db.Query("SELECT key, value FROM app_meta")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
meta[k] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if meta["admin_user"] == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
cfg := &ClicdConfig{
|
||||
AdminUser: meta["admin_user"],
|
||||
AdminPassHash: meta["admin_pass_hash"],
|
||||
JWTSecret: meta["jwt_secret"],
|
||||
Port: atoi(meta["port"]),
|
||||
DataDir: meta["data_dir"],
|
||||
NextContainerID: atoi(meta["next_container_id"]),
|
||||
NextVNCPort: atoi(meta["next_vnc_port"]),
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.SubUsers, err = loadSubUsers(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.ApiKeys, err = loadAPIKeys(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.AuditLogs, err = loadAuditLogs(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.Tasks, err = loadTasks(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.LoginLogs, err = loadLoginLogs(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.EnabledImages, err = loadEnabledImages(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if cfg.Snapshots, err = loadSnapshots(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
func saveConfigToDB() error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("sqlite database is not initialized")
|
||||
}
|
||||
dbMu.Lock()
|
||||
defer dbMu.Unlock()
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, table := range []string{
|
||||
"port_mappings",
|
||||
"sub_user_container_names",
|
||||
"sub_user_container_uuids",
|
||||
"containers",
|
||||
"sub_users",
|
||||
"api_keys",
|
||||
"audit_logs",
|
||||
"task_extra_ports",
|
||||
"tasks",
|
||||
"login_logs",
|
||||
"enabled_images",
|
||||
"snapshots",
|
||||
"app_meta",
|
||||
} {
|
||||
if _, err := tx.Exec("DELETE FROM " + table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := saveMeta(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveContainers(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveSubUsers(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveAPIKeys(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveAuditLogs(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveTasksDB(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveLoginLogs(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveEnabledImages(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveSnapshots(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func saveMeta(tx *sql.Tx) error {
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
"jwt_secret": AppConfig.JWTSecret,
|
||||
"port": strconv.Itoa(AppConfig.Port),
|
||||
"data_dir": AppConfig.DataDir,
|
||||
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
|
||||
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
for k, v := range values {
|
||||
if _, err := tx.Exec("INSERT INTO app_meta(key, value) VALUES (?, ?)", k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveContainers(tx *sql.Tx) error {
|
||||
for _, c := range AppConfig.Containers {
|
||||
if _, err := tx.Exec(`INSERT INTO containers (
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
|
||||
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, pm := range c.PortMappings {
|
||||
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveSubUsers(tx *sql.Tx) error {
|
||||
for _, su := range AppConfig.SubUsers {
|
||||
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, name := range su.ContainerNames {
|
||||
if _, err := tx.Exec(`INSERT INTO sub_user_container_names(sub_user_id, position, container_name) VALUES (?, ?, ?)`, su.ID, i, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, uuid := range su.ContainerUUIDs {
|
||||
if _, err := tx.Exec(`INSERT INTO sub_user_container_uuids(sub_user_id, position, container_uuid) VALUES (?, ?, ?)`, su.ID, i, uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAPIKeys(tx *sql.Tx) error {
|
||||
for _, k := range AppConfig.ApiKeys {
|
||||
if _, err := tx.Exec(`INSERT INTO api_keys(id, name, key_hash, prefix, ip_whitelist, created_at, last_used)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, k.ID, k.Name, k.KeyHash, k.Prefix, k.IPWhitelist, k.CreatedAt, k.LastUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAuditLogs(tx *sql.Tx) error {
|
||||
for _, log := range AppConfig.AuditLogs {
|
||||
successSet := 0
|
||||
success := 0
|
||||
if log.Success != nil {
|
||||
successSet = 1
|
||||
if *log.Success {
|
||||
success = 1
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO audit_logs(time, action, target, detail, user, ip, user_agent, success_set, success, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, log.Time, log.Action, log.Target, log.Detail, log.User, log.IP, log.UserAgent, successSet, success, log.Error); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveTasksDB(tx *sql.Tx) error {
|
||||
for _, task := range AppConfig.Tasks {
|
||||
cfg := parseSavedTaskConfig(task.Config)
|
||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
||||
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, port := range cfg.ExtraPorts {
|
||||
if _, err := tx.Exec(`INSERT INTO task_extra_ports(task_id, position, port) VALUES (?, ?, ?)`, task.ID, i, port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveLoginLogs(tx *sql.Tx) error {
|
||||
for _, log := range AppConfig.LoginLogs {
|
||||
if _, err := tx.Exec(`INSERT INTO login_logs(time, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)`,
|
||||
log.Time, log.Username, log.IP, log.UserAgent, boolInt(log.Success)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveEnabledImages(tx *sql.Tx) error {
|
||||
for i, id := range AppConfig.EnabledImages {
|
||||
if _, err := tx.Exec(`INSERT INTO enabled_images(position, image_id) VALUES (?, ?)`, i, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveSnapshots(tx *sql.Tx) error {
|
||||
for _, snapshot := range AppConfig.Snapshots {
|
||||
if _, err := tx.Exec(`INSERT INTO snapshots(id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot.ID, snapshot.ContainerID, snapshot.ContainerName, snapshot.LXCName, snapshot.CreatedAt, snapshot.CreatedBy, boolInt(snapshot.Scheduled), snapshot.Path, snapshot.SizeBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadContainers() ([]Container, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at
|
||||
FROM containers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := []Container{}
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
var scheduleEnabled, policyBlocked int
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps,
|
||||
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||
c.PolicyBlocked = policyBlocked != 0
|
||||
result = append(result, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range result {
|
||||
result[i].PortMappings, err = loadPortMappings(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadPortMappings(containerID int) ([]PortMapping, error) {
|
||||
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []PortMapping{}
|
||||
for rows.Next() {
|
||||
var pm PortMapping
|
||||
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, pm)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSubUsers() ([]SubUser, error) {
|
||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []SubUser{}
|
||||
for rows.Next() {
|
||||
var su SubUser
|
||||
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, su)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range result {
|
||||
result[i].ContainerNames, err = loadStringList("sub_user_container_names", "container_name", "sub_user_id", result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].ContainerUUIDs, err = loadStringList("sub_user_container_uuids", "container_uuid", "sub_user_id", result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadStringList(table, valueColumn, keyColumn, key string) ([]string, error) {
|
||||
rows, err := db.Query(fmt.Sprintf(`SELECT %s FROM %s WHERE %s = ? ORDER BY position`, valueColumn, table, keyColumn), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []string{}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
func loadAPIKeys() ([]ApiKeyConfig, error) {
|
||||
rows, err := db.Query(`SELECT id, name, key_hash, prefix, ip_whitelist, created_at, last_used FROM api_keys ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []ApiKeyConfig{}
|
||||
for rows.Next() {
|
||||
var k ApiKeyConfig
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.Prefix, &k.IPWhitelist, &k.CreatedAt, &k.LastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, k)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadAuditLogs() ([]AuditLog, error) {
|
||||
rows, err := db.Query(`SELECT time, action, target, detail, user, ip, user_agent, success_set, success, error FROM audit_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []AuditLog{}
|
||||
for rows.Next() {
|
||||
var log AuditLog
|
||||
var successSet, success int
|
||||
if err := rows.Scan(&log.Time, &log.Action, &log.Target, &log.Detail, &log.User, &log.IP, &log.UserAgent, &successSet, &success, &log.Error); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if successSet != 0 {
|
||||
value := success != 0
|
||||
log.Success = &value
|
||||
}
|
||||
result = append(result, log)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadTasks() ([]SavedTask, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
FROM tasks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []SavedTask{}
|
||||
configs := []savedTaskConfig{}
|
||||
for rows.Next() {
|
||||
var t SavedTask
|
||||
var cfg savedTaskConfig
|
||||
var assignIPv6 int
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User,
|
||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
||||
&assignIPv6, &cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.AssignIPv6 = assignIPv6 != 0
|
||||
result = append(result, t)
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range result {
|
||||
configs[i].ExtraPorts, err = loadTaskExtraPorts(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].Config = encodeSavedTaskConfig(configs[i])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadTaskExtraPorts(taskID string) ([]int, error) {
|
||||
rows, err := db.Query(`SELECT port FROM task_extra_ports WHERE task_id = ? ORDER BY position`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []int{}
|
||||
for rows.Next() {
|
||||
var port int
|
||||
if err := rows.Scan(&port); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, port)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadLoginLogs() ([]SavedLoginLog, error) {
|
||||
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []SavedLoginLog{}
|
||||
for rows.Next() {
|
||||
var log SavedLoginLog
|
||||
var success int
|
||||
if err := rows.Scan(&log.Time, &log.Username, &log.IP, &log.UserAgent, &success); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Success = success != 0
|
||||
result = append(result, log)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadEnabledImages() ([]string, error) {
|
||||
rows, err := db.Query(`SELECT image_id FROM enabled_images ORDER BY position`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSnapshots() ([]Snapshot, error) {
|
||||
rows, err := db.Query(`SELECT id, container_id, container_name, lxc_name, created_at, created_by, scheduled, path, size_bytes FROM snapshots ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Snapshot{}
|
||||
for rows.Next() {
|
||||
var snapshot Snapshot
|
||||
var scheduled int
|
||||
if err := rows.Scan(&snapshot.ID, &snapshot.ContainerID, &snapshot.ContainerName, &snapshot.LXCName, &snapshot.CreatedAt, &snapshot.CreatedBy, &scheduled, &snapshot.Path, &snapshot.SizeBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot.Scheduled = scheduled != 0
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadLegacyJSONConfig(path string) (*ClicdConfig, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to read legacy config: %v", err)
|
||||
}
|
||||
cfg := &ClicdConfig{}
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to parse legacy config: %v", err)
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func btoa(value bool) string {
|
||||
if value {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func atob(value string) bool {
|
||||
return value == "1" || strings.EqualFold(value, "true")
|
||||
}
|
||||
|
||||
func atoi(value string) int {
|
||||
n, _ := strconv.Atoi(value)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
resetConfigStoreForTest(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Cleanup(func() {
|
||||
resetConfigStoreForTest(t)
|
||||
})
|
||||
legacyPath := filepath.Join(dir, "config.json")
|
||||
SetConfigPath(legacyPath)
|
||||
|
||||
legacy := ClicdConfig{
|
||||
AdminUser: "admin",
|
||||
AdminPassHash: "hash",
|
||||
JWTSecret: "secret",
|
||||
Port: 8999,
|
||||
DataDir: dir,
|
||||
NextContainerID: 2,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
Containers: []Container{{
|
||||
ID: 1,
|
||||
UUID: "uuid-1",
|
||||
Name: "ct1",
|
||||
Virtualization: "lxc",
|
||||
Template: "debian-12",
|
||||
Status: "running",
|
||||
PortMappingLimit: 2,
|
||||
SnapshotLimit: 3,
|
||||
PortMappings: []PortMapping{{
|
||||
ContainerPort: 22,
|
||||
HostPort: 22001,
|
||||
Protocol: "tcp",
|
||||
Description: "SSH",
|
||||
}},
|
||||
}},
|
||||
AuditLogs: []AuditLog{{
|
||||
Time: "2026-06-07 17:29:00",
|
||||
Action: "security_horizontal_scan",
|
||||
Target: "ct1",
|
||||
Detail: "[medium] 可疑横向探测",
|
||||
User: "system",
|
||||
}},
|
||||
LoginLogs: []SavedLoginLog{{
|
||||
Time: "2026-06-07 17:29:01 CST",
|
||||
Username: "admin",
|
||||
IP: "127.0.0.1",
|
||||
UserAgent: "test",
|
||||
Success: true,
|
||||
}},
|
||||
Tasks: []SavedTask{{
|
||||
ID: "task-1",
|
||||
Type: "create",
|
||||
ContainerName: "ct2",
|
||||
Status: "pending",
|
||||
CreatedAt: "2026-06-07 17:29:02",
|
||||
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`,
|
||||
}},
|
||||
EnabledImages: []string{"debian-12"},
|
||||
Snapshots: []Snapshot{{
|
||||
ID: "snap-1",
|
||||
ContainerID: 1,
|
||||
ContainerName: "ct1",
|
||||
LXCName: "ct-1",
|
||||
CreatedAt: "2026-06-07 17:30:00",
|
||||
Path: filepath.Join(dir, "snap-1"),
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyPath, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := InitConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Containers) != 1 || len(cfg.Containers[0].PortMappings) != 1 {
|
||||
t.Fatalf("legacy config was not migrated: %+v", cfg.Containers)
|
||||
}
|
||||
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
|
||||
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
|
||||
t.Fatalf("sqlite database was not created: %v", err)
|
||||
}
|
||||
|
||||
cfg.Containers[0].Status = "stopped"
|
||||
if err := SaveConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConfigStoreForTest(t)
|
||||
SetConfigPath(legacyPath)
|
||||
cfg, err = InitConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.Containers[0].Status; got != "stopped" {
|
||||
t.Fatalf("expected sqlite value to win after migration, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func resetConfigStoreForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
if db != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = nil
|
||||
}
|
||||
AppConfig = nil
|
||||
configPath = ""
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func GetImages() []Image {
|
||||
return []Image{
|
||||
{
|
||||
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
||||
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
||||
Description: "Ubuntu 24.04 LTS cloud image for KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
|
||||
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
||||
Description: "Ubuntu 22.04 LTS cloud image for KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||
Description: "Debian 12 generic cloud image for KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
|
||||
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
||||
Description: "Debian 11 generic cloud image for KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-alpine-3.23", Name: "Alpine 3.23 KVM",
|
||||
Distro: "alpine", Release: "3.23", Arch: "amd64",
|
||||
Description: "Alpine Linux 3.23 NoCloud cloud-init image for KVM",
|
||||
URL: "https://dev.alpinelinux.org/~tomalok/alpine-cloud-images/v3.23/nocloud/x86_64/nocloud_alpine-3.23.4-x86_64-bios-cloudinit-r0.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
|
||||
Distro: "centos", Release: "9-stream", Arch: "amd64",
|
||||
Description: "CentOS Stream 9 GenericCloud image for KVM",
|
||||
URL: "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-archlinux-current", Name: "Arch Linux KVM",
|
||||
Distro: "archlinux", Release: "current", Arch: "amd64",
|
||||
Description: "Arch Linux (Rolling) cloud image for KVM",
|
||||
URL: "https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
|
||||
Distro: "fedora", Release: "44", Arch: "amd64",
|
||||
Description: "Fedora 44 GenericCloud image for KVM",
|
||||
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
|
||||
Distro: "rockylinux", Release: "9", Arch: "amd64",
|
||||
Description: "Rocky Linux 9 GenericCloud image for KVM",
|
||||
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
||||
Distro: "windows", Release: "10", Arch: "amd64",
|
||||
Description: "Windows 10 Enterprise LTSC Evaluation",
|
||||
URL: "https://go.microsoft.com/fwlink/?LinkID=2195404",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func FindImage(id string) *Image {
|
||||
for _, image := range GetImages() {
|
||||
if image.ID == id {
|
||||
return &image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CacheDir() string {
|
||||
return filepath.Join(BaseDir(), "images")
|
||||
}
|
||||
|
||||
func ImagePath(id string) string {
|
||||
img := FindImage(id)
|
||||
ext := ".qcow2"
|
||||
if img != nil && img.Distro == "windows" {
|
||||
ext = ".iso"
|
||||
}
|
||||
return filepath.Join(CacheDir(), id+ext)
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image distro is "windows".
|
||||
func IsWindowsImage(id string) bool {
|
||||
img := FindImage(id)
|
||||
return img != nil && img.Distro == "windows"
|
||||
}
|
||||
|
||||
func virtioWinISOPath() string {
|
||||
return filepath.Join(CacheDir(), "virtio-win.iso")
|
||||
}
|
||||
@@ -15,7 +15,7 @@ func IsExpired(c config.Container) bool {
|
||||
// StopExpiredContainers stops running containers whose expiration date has passed.
|
||||
func (m *Manager) StopExpiredContainers(now time.Time) {
|
||||
for _, container := range config.AppConfig.Containers {
|
||||
if !isContainerExpired(container, now) {
|
||||
if container.IsKVM() || !isContainerExpired(container, now) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
|
||||
saved := false
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" {
|
||||
if c.IsKVM() || c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -428,6 +428,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
||||
if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
|
||||
if _, err := os.Stat(rootfsPath); err == nil {
|
||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
}
|
||||
if err := m.ApplyIPv6(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -477,6 +483,12 @@ func (m *Manager) ApplyIPv6(id int) error {
|
||||
config.SaveConfig()
|
||||
}
|
||||
|
||||
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
|
||||
if _, err := os.Stat(rootfsPath); err == nil {
|
||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
}
|
||||
if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -485,12 +497,16 @@ func (m *Manager) ApplyIPv6(id int) error {
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c",
|
||||
fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0",
|
||||
fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0 metric 100",
|
||||
shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal)))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output))
|
||||
}
|
||||
removeIPv6NAT66(c.IPv6, c.IPv6Interface)
|
||||
if !containerIPv6ConnectivityOK(c.LxcName()) {
|
||||
ensureIPv6NAT66(c.IPv6, c.IPv6Interface)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -513,6 +529,191 @@ func ensureHostIPv6Routing(ipv6, uplink string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func installContainerIPv6Init(rootfsPath, ipv6 string) error {
|
||||
if strings.TrimSpace(ipv6) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := netip.ParseAddr(ipv6); err != nil {
|
||||
return fmt.Errorf("invalid IPv6 address %q: %w", ipv6, err)
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init")
|
||||
if err := os.MkdirAll(filepath.Dir(scriptPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
script := `#!/bin/sh
|
||||
IPV6_ADDR=` + shellQuote(ipv6) + `
|
||||
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
|
||||
IFACE="${CLICD_IPV6_IFACE:-eth0}"
|
||||
|
||||
command -v ip >/dev/null 2>&1 || exit 0
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if ip link show dev "$IFACE" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
ip link set dev "$IFACE" up >/dev/null 2>&1 || true
|
||||
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE" >/dev/null 2>&1 || true
|
||||
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" metric 100 >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
osRelease := ""
|
||||
if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil {
|
||||
osRelease = strings.ToLower(string(data))
|
||||
}
|
||||
hasSystemd := dirExists(filepath.Join(rootfsPath, "etc", "systemd", "system"))
|
||||
hasOpenRC := fileExists(filepath.Join(rootfsPath, "sbin", "openrc-run")) || strings.Contains(osRelease, "alpine")
|
||||
|
||||
if hasSystemd {
|
||||
if err := installContainerIPv6Systemd(rootfsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hasOpenRC {
|
||||
if err := installContainerIPv6OpenRC(rootfsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !hasSystemd && !hasOpenRC {
|
||||
if err := installContainerIPv6SysV(rootfsPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installContainerIPv6Systemd(rootfsPath string) error {
|
||||
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
||||
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
service := `[Unit]
|
||||
Description=CLICD IPv6 setup
|
||||
After=network-online.target network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/clicd-ipv6-init
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
if err := os.WriteFile(servicePath, []byte(service), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
wantsDir := filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceSymlink("../clicd-ipv6.service", filepath.Join(wantsDir, "clicd-ipv6.service"))
|
||||
}
|
||||
|
||||
func installContainerIPv6OpenRC(rootfsPath string) error {
|
||||
initPath := filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6")
|
||||
if err := os.MkdirAll(filepath.Dir(initPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
initScript := `#!/sbin/openrc-run
|
||||
name="CLICD IPv6 setup"
|
||||
description="Apply CLICD IPv6 settings"
|
||||
|
||||
depend() {
|
||||
after net networking
|
||||
need net
|
||||
}
|
||||
|
||||
start() {
|
||||
ebegin "Applying CLICD IPv6"
|
||||
/usr/local/sbin/clicd-ipv6-init
|
||||
eend $?
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(initPath, []byte(initScript), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
runlevelDir := filepath.Join(rootfsPath, "etc", "runlevels", "default")
|
||||
if err := os.MkdirAll(runlevelDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceSymlink(filepath.Join("..", "..", "init.d", "clicd-ipv6"), filepath.Join(runlevelDir, "clicd-ipv6"))
|
||||
}
|
||||
|
||||
func installContainerIPv6SysV(rootfsPath string) error {
|
||||
initDir := filepath.Join(rootfsPath, "etc", "init.d")
|
||||
if err := os.MkdirAll(initDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
initPath := filepath.Join(initDir, "clicd-ipv6")
|
||||
initScript := `#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: clicd-ipv6
|
||||
# Required-Start: $network
|
||||
# Required-Stop:
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop:
|
||||
# Short-Description: CLICD IPv6 setup
|
||||
### END INIT INFO
|
||||
|
||||
case "$1" in
|
||||
start|restart|force-reload)
|
||||
/usr/local/sbin/clicd-ipv6-init
|
||||
;;
|
||||
stop|status)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|force-reload|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(initPath, []byte(initScript), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, level := range []string{"2", "3", "4", "5"} {
|
||||
rcDir := filepath.Join(rootfsPath, "etc", "rc"+level+".d")
|
||||
if !dirExists(rcDir) {
|
||||
continue
|
||||
}
|
||||
if err := replaceSymlink(filepath.Join("..", "init.d", "clicd-ipv6"), filepath.Join(rcDir, "S99clicd-ipv6")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceSymlink(target, linkPath string) error {
|
||||
if current, err := os.Readlink(linkPath); err == nil && current == target {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(target, linkPath)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func dirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func ensureIPv6ForwardRules(ipv6 string) {
|
||||
rules := [][]string{
|
||||
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"},
|
||||
@@ -527,6 +728,59 @@ func ensureIPv6ForwardRules(ipv6 string) {
|
||||
}
|
||||
}
|
||||
|
||||
func removeHostIPv6Routing(ipv6, uplink string) {
|
||||
removeIPv6NAT66(ipv6, uplink)
|
||||
removeIPv6ForwardRules(ipv6)
|
||||
runQuiet("ip", "-6", "route", "del", ipv6+"/128", "dev", "lxcbr0")
|
||||
if uplink != "" {
|
||||
runQuiet("ip", "-6", "neigh", "del", "proxy", ipv6, "dev", uplink)
|
||||
}
|
||||
}
|
||||
|
||||
func removeIPv6ForwardRules(ipv6 string) {
|
||||
rules := [][]string{
|
||||
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"},
|
||||
{"FORWARD", "-o", "lxcbr0", "-d", ipv6 + "/128", "-j", "ACCEPT"},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
del := append([]string{"-D"}, rule...)
|
||||
for exec.Command("ip6tables", del...).Run() == nil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containerIPv6ConnectivityOK(lxcName string) bool {
|
||||
targets := []string{"2606:4700:4700::1111", "2001:4860:4860::8888"}
|
||||
for _, target := range targets {
|
||||
if exec.Command("lxc-attach", "-n", lxcName, "--", "ping", "-6", "-c", "1", "-W", "2", target).Run() == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureIPv6NAT66(ipv6, uplink string) {
|
||||
if ipv6 == "" || uplink == "" {
|
||||
return
|
||||
}
|
||||
rule := []string{"POSTROUTING", "-s", ipv6 + "/128", "-o", uplink, "-j", "MASQUERADE"}
|
||||
check := append([]string{"-t", "nat", "-C"}, rule...)
|
||||
add := append([]string{"-t", "nat", "-A"}, rule...)
|
||||
if exec.Command("ip6tables", check...).Run() != nil {
|
||||
exec.Command("ip6tables", add...).Run()
|
||||
}
|
||||
}
|
||||
|
||||
func removeIPv6NAT66(ipv6, uplink string) {
|
||||
if ipv6 == "" || uplink == "" {
|
||||
return
|
||||
}
|
||||
rule := []string{"POSTROUTING", "-s", ipv6 + "/128", "-o", uplink, "-j", "MASQUERADE"}
|
||||
del := append([]string{"-t", "nat", "-D"}, rule...)
|
||||
for exec.Command("ip6tables", del...).Run() == nil {
|
||||
}
|
||||
}
|
||||
|
||||
func runQuiet(name string, args ...string) {
|
||||
_ = exec.Command(name, args...).Run()
|
||||
}
|
||||
|
||||
+281
-31
@@ -1,6 +1,7 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -14,9 +15,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
@@ -72,11 +74,17 @@ func (m *Manager) WarmRunningContainersSSH() {
|
||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||
for _, container := range containers {
|
||||
c := container
|
||||
if c.IsKVM() {
|
||||
continue
|
||||
}
|
||||
status, err := m.GetContainerStatus(c.LxcName())
|
||||
if err != nil || status != "running" {
|
||||
continue
|
||||
}
|
||||
config.UpdateContainerStatus(c.ID, "running")
|
||||
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
|
||||
continue
|
||||
}
|
||||
m.WarmSSHAsync(c.ID, "running container scan")
|
||||
}
|
||||
}
|
||||
@@ -98,6 +106,11 @@ func (m *Manager) updateAllRates() {
|
||||
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.IsKVM() {
|
||||
delete(lastUsage, c.VirshName())
|
||||
delete(rateCache, c.VirshName())
|
||||
continue
|
||||
}
|
||||
if c.Status != "running" {
|
||||
delete(lastUsage, c.LxcName())
|
||||
delete(rateCache, c.LxcName())
|
||||
@@ -105,19 +118,23 @@ func (m *Manager) updateAllRates() {
|
||||
}
|
||||
lxcName := c.LxcName()
|
||||
|
||||
// Read raw bytes
|
||||
memUsage := readIntCommand(fmt.Sprintf(
|
||||
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
|
||||
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
|
||||
"cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName)))
|
||||
// Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes
|
||||
// don't each fork lxc-info separately.
|
||||
initPID := m.getContainerInitPID(lxcName)
|
||||
|
||||
cpuUsec := uint64(readIntCommand(fmt.Sprintf(
|
||||
"(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+
|
||||
"cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+
|
||||
"awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName))))
|
||||
// Read memory from cgroup directly (no shell fork)
|
||||
memUsage := readCgroupFile(lxcName,
|
||||
"/sys/fs/cgroup/lxc/%s/memory.current",
|
||||
"/sys/fs/cgroup/lxc.payload.%s/memory.current",
|
||||
"/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes")
|
||||
|
||||
rxBytes, txBytes := m.getContainerNetworkBytes(lxcName)
|
||||
readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName)
|
||||
// Read cpu usage from cgroup directly (no shell | awk fork)
|
||||
cpuUsec := readCgroupCPUUsec(lxcName,
|
||||
"/sys/fs/cgroup/lxc/%s/cpu.stat",
|
||||
"/sys/fs/cgroup/lxc.payload.%s/cpu.stat")
|
||||
|
||||
rxBytes, txBytes := getNetworkBytesForPID(initPID)
|
||||
readBytes, writeBytes := getDiskIOBytesForPID(initPID)
|
||||
|
||||
now := time.Now()
|
||||
sample := containerUsageSample{
|
||||
@@ -203,6 +220,7 @@ func NewManager() *Manager {
|
||||
// ContainerConfig defines container creation parameters
|
||||
type ContainerConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
@@ -216,6 +234,7 @@ type ContainerConfig struct {
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
@@ -226,6 +245,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
||||
}
|
||||
if cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
|
||||
if !config.IsValidContainerName(cfg.Name) {
|
||||
return fmt.Errorf("invalid container name: %s", cfg.Name)
|
||||
@@ -330,6 +355,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
ID: id,
|
||||
UUID: config.NewContainerUUID(),
|
||||
Name: cfg.Name,
|
||||
Virtualization: config.VirtualizationLXC,
|
||||
Template: cfg.TemplateID,
|
||||
VCPU: cfg.VCPU,
|
||||
RAMMB: cfg.RAMMB,
|
||||
@@ -351,6 +377,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
SSHPassword: sshPassword,
|
||||
PortMappings: portMappings,
|
||||
PortMappingLimit: cfg.PortMappingCount,
|
||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: cfg.ExpiresAt,
|
||||
}
|
||||
@@ -359,12 +386,18 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
// Pre-configure network and SSH in the rootfs before first boot.
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
|
||||
if ipv6 != "" {
|
||||
if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
||||
}
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, sshPassword, cfg.TemplateID); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
|
||||
}
|
||||
|
||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
config.RemoveContainer(id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -441,13 +474,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))
|
||||
@@ -967,6 +1000,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().(*unix.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 {
|
||||
@@ -976,10 +1019,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
stat, ok := info.Sys().(*unix.Stat_t)
|
||||
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 {
|
||||
@@ -991,7 +1040,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if gid >= 0 && gid < 65536 {
|
||||
gid += gidBase
|
||||
}
|
||||
return syscall.Lchown(path, uid, gid)
|
||||
return unix.Lchown(path, uid, gid)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to shift rootfs ownership for unprivileged LXC: %v", err)
|
||||
}
|
||||
@@ -999,7 +1048,7 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
||||
if err := os.WriteFile(marker, []byte("1\n"), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syscall.Lchown(marker, uidBase, gidBase); err != nil {
|
||||
if err := unix.Lchown(marker, uidBase, gidBase); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1014,6 +1063,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)
|
||||
@@ -1411,6 +1488,9 @@ func (m *Manager) DestroyContainer(id int) error {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
lxcName := c.LxcName()
|
||||
if c.IPv6 != "" && c.IPv6Interface != "" {
|
||||
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
|
||||
}
|
||||
|
||||
if err := m.StopContainer(id); err != nil {
|
||||
return fmt.Errorf("failed to stop container before destroy: %v", err)
|
||||
@@ -1458,6 +1538,19 @@ func (m *Manager) DestroyContainer(id int) error {
|
||||
}
|
||||
return fmt.Errorf("container still exists after cleanup with status %s", status)
|
||||
}
|
||||
// Remove snapshot physical files (by container ID, not lxcName)
|
||||
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id))
|
||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
|
||||
os.RemoveAll(snapshotDir)
|
||||
}
|
||||
|
||||
// Also remove any legacy snapshot dir that used lxcName
|
||||
legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
|
||||
if legacySnapshotDir != snapshotDir {
|
||||
if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil {
|
||||
os.RemoveAll(legacySnapshotDir)
|
||||
}
|
||||
}
|
||||
|
||||
if !config.RemoveContainer(id) {
|
||||
return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
|
||||
@@ -1493,12 +1586,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))
|
||||
@@ -1606,7 +1699,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
|
||||
@@ -1901,6 +1997,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
|
||||
func (m *Manager) ListContainers() ([]config.Container, error) {
|
||||
containers := config.AppConfig.Containers
|
||||
for i := range containers {
|
||||
if containers[i].IsKVM() {
|
||||
continue
|
||||
}
|
||||
status, err := m.GetContainerStatus(containers[i].LxcName())
|
||||
if err == nil {
|
||||
containers[i].Status = status
|
||||
@@ -1976,6 +2075,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
||||
UUID: config.NewContainerUUID(),
|
||||
Name: name,
|
||||
LXCName: lxcName,
|
||||
Virtualization: config.VirtualizationLXC,
|
||||
Template: "imported",
|
||||
VCPU: 1,
|
||||
RAMMB: 512,
|
||||
@@ -1986,6 +2086,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
||||
Status: status,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
PortMappingLimit: 2,
|
||||
SnapshotLimit: config.DefaultSnapshotLimit,
|
||||
}
|
||||
|
||||
if status == "running" {
|
||||
@@ -2088,6 +2189,11 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
// Set root password and pre-configure network/SSH via chroot.
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
m.preconfigureNetwork(rootfsPath, templateID)
|
||||
if c.IPv6 != "" {
|
||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
}
|
||||
if c.SSHPassword == "" {
|
||||
c.SSHPassword = generateRandomString(16)
|
||||
}
|
||||
@@ -2103,6 +2209,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
|
||||
// Update template and keep everything else the same
|
||||
c.Template = templateID
|
||||
c.SSHHostKey = ""
|
||||
c.Status = "running"
|
||||
config.SaveConfig()
|
||||
|
||||
@@ -2166,6 +2273,11 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
|
||||
usage := make(map[string]interface{})
|
||||
|
||||
// Read raw values
|
||||
load1, load5, load15 := m.getContainerLoadAvg(lxcName)
|
||||
usage["load1"] = load1
|
||||
usage["load5"] = load5
|
||||
usage["load15"] = load15
|
||||
|
||||
memUsage := readIntCommand(fmt.Sprintf(
|
||||
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
|
||||
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
|
||||
@@ -2213,15 +2325,62 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (m *Manager) getContainerLoadAvg(lxcName string) (float64, float64, float64) {
|
||||
pid := m.getContainerInitPID(lxcName)
|
||||
if pid == "" {
|
||||
return 0, 0, 0
|
||||
}
|
||||
out, err := exec.Command("nsenter", "-t", pid, "-m", "-p", "cat", "/proc/loadavg").Output()
|
||||
if err != nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) < 3 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(parts[0], 64)
|
||||
load5, _ := strconv.ParseFloat(parts[1], 64)
|
||||
load15, _ := strconv.ParseFloat(parts[2], 64)
|
||||
return load1, load5, load15
|
||||
}
|
||||
|
||||
func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
|
||||
pid := m.getContainerInitPID(lxcName)
|
||||
if pid == "" {
|
||||
return 0, 0
|
||||
}
|
||||
dir := fmt.Sprintf("/proc/%s/net", pid)
|
||||
rx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print rx}' || echo 0", shellQuote(dir)))
|
||||
tx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print tx}' || echo 0", shellQuote(dir)))
|
||||
return uint64(rx), uint64(tx)
|
||||
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
|
||||
}
|
||||
|
||||
// readProcNetDev parses /proc/PID/net/dev directly (no shell/awk fork).
|
||||
func readProcNetDev(path string) (uint64, uint64) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var rx, tx uint64
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Skip header lines
|
||||
if strings.Contains(line, "|") || strings.Contains(line, "face") || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
// Fields: face | rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast | tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed
|
||||
// Skip loopback (face starts with "lo")
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(fields[0], "lo") {
|
||||
continue
|
||||
}
|
||||
r, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
t, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
rx += r
|
||||
tx += t
|
||||
}
|
||||
return rx, tx
|
||||
}
|
||||
|
||||
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
||||
@@ -2229,10 +2388,22 @@ func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
||||
if pid == "" {
|
||||
return 0, 0
|
||||
}
|
||||
// /proc/PID/io format: "field_name: value" per line
|
||||
// Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes
|
||||
readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
|
||||
writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var readBytes, writeBytes uint64
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "read_bytes:") {
|
||||
val := strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:"))
|
||||
readBytes, _ = strconv.ParseUint(val, 10, 64)
|
||||
} else if strings.HasPrefix(line, "write_bytes:") {
|
||||
val := strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:"))
|
||||
writeBytes, _ = strconv.ParseUint(val, 10, 64)
|
||||
}
|
||||
}
|
||||
return readBytes, writeBytes
|
||||
}
|
||||
|
||||
@@ -2245,6 +2416,73 @@ func (m *Manager) getContainerInitPID(lxcName string) string {
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// readCgroupFile tries each path template in order, reads the file directly (no shell),
|
||||
// and returns the first valid int64 value.
|
||||
func readCgroupFile(name string, paths ...string) int64 {
|
||||
for _, tmpl := range paths {
|
||||
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
if err == nil && val > 0 {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// readCgroupCPUUsec tries each path template, reads cpu.stat, and extracts usage_usec.
|
||||
func readCgroupCPUUsec(name string, paths ...string) uint64 {
|
||||
for _, tmpl := range paths {
|
||||
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "usage_usec ") {
|
||||
val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "usage_usec")), 10, 64)
|
||||
if err == nil {
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getNetworkBytesForPID reads /proc/PID/net/dev for a given PID (no lxc-info needed).
|
||||
func getNetworkBytesForPID(pid string) (uint64, uint64) {
|
||||
if pid == "" {
|
||||
return 0, 0
|
||||
}
|
||||
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
|
||||
}
|
||||
|
||||
// getDiskIOBytesForPID reads /proc/PID/io for a given PID (no lxc-info needed).
|
||||
func getDiskIOBytesForPID(pid string) (uint64, uint64) {
|
||||
if pid == "" {
|
||||
return 0, 0
|
||||
}
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var readBytes, writeBytes uint64
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "read_bytes:") {
|
||||
readBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64)
|
||||
} else if strings.HasPrefix(line, "write_bytes:") {
|
||||
writeBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64)
|
||||
}
|
||||
}
|
||||
return readBytes, writeBytes
|
||||
}
|
||||
|
||||
// getContainerUptimeSeconds returns how long the container has been running (in seconds).
|
||||
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
|
||||
pid := m.getContainerInitPID(lxcName)
|
||||
@@ -2372,6 +2610,7 @@ func (m *Manager) AccumulateTraffic() {
|
||||
lastTrafficSnapshotMu.Lock()
|
||||
defer lastTrafficSnapshotMu.Unlock()
|
||||
|
||||
changed := false
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" {
|
||||
@@ -2379,23 +2618,34 @@ func (m *Manager) AccumulateTraffic() {
|
||||
delete(lastTrafficSnapshot, c.LxcName())
|
||||
continue
|
||||
}
|
||||
if c.IsKVM() {
|
||||
continue
|
||||
}
|
||||
// Reset if new month
|
||||
if c.TrafficResetDate != currentMonth {
|
||||
c.TrafficUsedRX = 0
|
||||
c.TrafficUsedTX = 0
|
||||
c.TrafficResetDate = currentMonth
|
||||
delete(lastTrafficSnapshot, c.LxcName())
|
||||
changed = true
|
||||
}
|
||||
rx, tx := m.getContainerNetworkBytes(c.LxcName())
|
||||
prev, exists := lastTrafficSnapshot[c.LxcName()]
|
||||
// Only add the DELTA (increment since last snapshot)
|
||||
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
|
||||
c.TrafficUsedRX += int64(rx - prev.RXBytes)
|
||||
c.TrafficUsedTX += int64(tx - prev.TXBytes)
|
||||
deltaRX := int64(rx - prev.RXBytes)
|
||||
deltaTX := int64(tx - prev.TXBytes)
|
||||
if deltaRX > 0 || deltaTX > 0 {
|
||||
c.TrafficUsedRX += deltaRX
|
||||
c.TrafficUsedTX += deltaTX
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
|
||||
}
|
||||
config.SaveConfig()
|
||||
if changed {
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// GetTrafficInfo returns traffic usage info for a container
|
||||
|
||||
@@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
return fmt.Errorf("container has no IP")
|
||||
}
|
||||
tag := clicdTag(id)
|
||||
bridge := "lxcbr0"
|
||||
subnet := "10.0.3.0/24"
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
subnet = "192.168.122.0/24"
|
||||
}
|
||||
|
||||
EnsureForwardRules()
|
||||
EnsureForwardRules(bridge)
|
||||
m.CleanPortMappings(id)
|
||||
|
||||
for _, pm := range c.PortMappings {
|
||||
@@ -41,8 +47,8 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
||||
}
|
||||
|
||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
|
||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
|
||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
|
||||
func EnsureForwardRules() {
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||
func EnsureForwardRules(bridge string) {
|
||||
if bridge == "" {
|
||||
bridge = "lxcbr0"
|
||||
}
|
||||
rules := [][]string{
|
||||
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-i", bridge, "-j", "ACCEPT"},
|
||||
{"-o", bridge, "-j", "ACCEPT"},
|
||||
{"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, args := range rules {
|
||||
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
|
||||
if exec.Command("iptables", checkArgs...).Run() != nil {
|
||||
exec.Command("iptables", args...).Run()
|
||||
for {
|
||||
deleteArgs := append([]string{"-D", "FORWARD"}, args...)
|
||||
if exec.Command("iptables", deleteArgs...).Run() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
|
||||
exec.Command("iptables", insertArgs...).Run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,12 +174,24 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
if pm.HostPort <= 0 {
|
||||
pm.HostPort = pm.ContainerPort
|
||||
}
|
||||
// Check current container's own mappings
|
||||
for i, existing := range c.PortMappings {
|
||||
if i == skipIndex {
|
||||
continue
|
||||
}
|
||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
||||
return pm, fmt.Errorf("host port %d/%s already mapped", pm.HostPort, pm.Protocol)
|
||||
return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
|
||||
}
|
||||
}
|
||||
// Check all other containers (LXC + KVM) for port conflicts
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
for _, existing := range oc.PortMappings {
|
||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
||||
return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pm, nil
|
||||
@@ -177,10 +202,20 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
return nil
|
||||
}
|
||||
used := map[int]bool{}
|
||||
// Mark current container's ports
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
used[pm.ContainerPort] = true
|
||||
}
|
||||
// Also mark all other containers' host ports (LXC + KVM)
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
next := 20000
|
||||
for len(ports) < count {
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
var snapshotMu sync.Mutex
|
||||
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return config.Snapshot{}, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if scheduled && rotateLimit > 0 {
|
||||
for {
|
||||
existing := config.ContainerSnapshots(id)
|
||||
if len(existing) < rotateLimit {
|
||||
break
|
||||
}
|
||||
sortSnapshotsOldestFirst(existing)
|
||||
if err := m.deleteSnapshotLocked(existing[0]); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lxcName := c.LxcName()
|
||||
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||
if _, err := os.Stat(containerDir); err != nil {
|
||||
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
||||
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
|
||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
|
||||
wasRunning, err := m.prepareContainerForColdCopy(id, lxcName, containerDir)
|
||||
if err != nil {
|
||||
os.RemoveAll(snapshotDir)
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
if wasRunning {
|
||||
defer func() {
|
||||
if err := m.StartContainer(id); err != nil {
|
||||
fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", lxcName, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := copyTree(containerDir, snapshotDir); err != nil {
|
||||
os.RemoveAll(snapshotDir)
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
|
||||
snapshot := config.Snapshot{
|
||||
ID: snapshotID,
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: lxcName,
|
||||
CreatedAt: now.Format("2006-01-02 15:04:05"),
|
||||
CreatedBy: createdBy,
|
||||
Scheduled: scheduled,
|
||||
Path: snapshotDir,
|
||||
SizeBytes: dirSizeBytes(snapshotDir),
|
||||
}
|
||||
config.AddSnapshot(snapshot)
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (m *Manager) DeleteSnapshot(id string) error {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
snapshot := config.FindSnapshot(id)
|
||||
if snapshot == nil {
|
||||
return fmt.Errorf("snapshot not found: %s", id)
|
||||
}
|
||||
return m.deleteSnapshotLocked(*snapshot)
|
||||
}
|
||||
|
||||
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||
if snapshot.Path != "" {
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
return fmt.Errorf("failed to delete snapshot files: %v", err)
|
||||
}
|
||||
}
|
||||
config.RemoveSnapshot(snapshot.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) RestoreSnapshot(id string) error {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
snapshot := config.FindSnapshot(id)
|
||||
if snapshot == nil {
|
||||
return fmt.Errorf("snapshot not found: %s", id)
|
||||
}
|
||||
if snapshot.Path == "" {
|
||||
return fmt.Errorf("snapshot path is empty")
|
||||
}
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||
return fmt.Errorf("snapshot files not found: %v", err)
|
||||
}
|
||||
|
||||
c := config.FindContainer(snapshot.ContainerID)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", snapshot.ContainerID)
|
||||
}
|
||||
lxcName := c.LxcName()
|
||||
containerDir := filepath.Join(m.LxcPath, lxcName)
|
||||
if err := safePathUnder(containerDir, m.LxcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wasRunning, err := m.prepareContainerForColdCopy(c.ID, lxcName, containerDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(m.LxcPath, fmt.Sprintf(".%s-restore-backup-%d", lxcName, time.Now().UnixNano()))
|
||||
if err := safePathUnder(backupDir, m.LxcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(containerDir, backupDir); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to move current container aside: %v", err)
|
||||
}
|
||||
|
||||
if err := copyTree(snapshot.Path, containerDir); err != nil {
|
||||
os.RemoveAll(containerDir)
|
||||
_ = os.Rename(backupDir, containerDir)
|
||||
return fmt.Errorf("failed to restore snapshot: %v", err)
|
||||
}
|
||||
_ = os.RemoveAll(backupDir)
|
||||
|
||||
config.UpdateContainerStatus(c.ID, "stopped")
|
||||
if wasRunning {
|
||||
return m.StartContainer(c.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if intervalHours < 24 {
|
||||
return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours")
|
||||
}
|
||||
if _, err := parseScheduleClock(scheduleTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SnapshotScheduleEnabled = enabled
|
||||
c.SnapshotScheduleIntervalHours = intervalHours
|
||||
c.SnapshotScheduleTime = scheduleTime
|
||||
c.SnapshotScheduleCreatedBy = createdBy
|
||||
if enabled {
|
||||
c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339)
|
||||
} else {
|
||||
c.SnapshotScheduleNextRun = ""
|
||||
}
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) StartSnapshotScheduler() {
|
||||
go func() {
|
||||
m.runDueSnapshotSchedules()
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
m.runDueSnapshotSchedules()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) runDueSnapshotSchedules() {
|
||||
now := time.Now()
|
||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||
for _, c := range containers {
|
||||
if c.IsKVM() || !c.SnapshotScheduleEnabled {
|
||||
continue
|
||||
}
|
||||
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
|
||||
if err != nil || c.SnapshotScheduleNextRun == "" {
|
||||
nextRun = now
|
||||
}
|
||||
if now.Before(nextRun) {
|
||||
continue
|
||||
}
|
||||
createdBy := c.SnapshotScheduleCreatedBy
|
||||
if createdBy == "" {
|
||||
createdBy = "admin"
|
||||
}
|
||||
rotateLimit := 0
|
||||
if strings.HasPrefix(createdBy, "user:") {
|
||||
rotateLimit = config.ContainerSnapshotLimit(&c)
|
||||
}
|
||||
if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil {
|
||||
fmt.Printf("Warning: scheduled snapshot failed for %s: %v\n", c.Name, err)
|
||||
continue
|
||||
}
|
||||
if current := config.FindContainer(c.ID); current != nil {
|
||||
interval := current.SnapshotScheduleIntervalHours
|
||||
if interval < 24 {
|
||||
interval = 24
|
||||
}
|
||||
next := nextRun.Add(time.Duration(interval) * time.Hour)
|
||||
for !next.After(now) {
|
||||
next = next.Add(time.Duration(interval) * time.Hour)
|
||||
}
|
||||
current.SnapshotScheduleLastRun = now.Format(time.RFC3339)
|
||||
current.SnapshotScheduleNextRun = next.Format(time.RFC3339)
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseScheduleClock(value string) (time.Duration, error) {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, fmt.Errorf("snapshot schedule time must be HH:MM")
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return 0, fmt.Errorf("snapshot schedule hour must be 00-23")
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return 0, fmt.Errorf("snapshot schedule minute must be 00-59")
|
||||
}
|
||||
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
|
||||
}
|
||||
|
||||
func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time {
|
||||
clock, err := parseScheduleClock(scheduleTime)
|
||||
if err != nil {
|
||||
clock = 3 * time.Hour
|
||||
}
|
||||
midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
|
||||
next := midnight.Add(clock)
|
||||
interval := time.Duration(intervalHours) * time.Hour
|
||||
for !next.After(from) {
|
||||
next = next.Add(interval)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerDir string) (bool, error) {
|
||||
status, _ := m.GetContainerStatus(lxcName)
|
||||
wasRunning := status == "running"
|
||||
if wasRunning {
|
||||
if err := m.StopContainer(id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
} else if c := config.FindContainer(id); c != nil {
|
||||
m.CleanPortMappings(id)
|
||||
m.cleanupBandwidthLimit(c.LxcName())
|
||||
}
|
||||
rootfs := filepath.Join(containerDir, "rootfs")
|
||||
exec.Command("umount", "-R", "-l", rootfs).Run()
|
||||
m.detachContainerMounts(containerDir)
|
||||
m.detachContainerLoopDevices(containerDir)
|
||||
return wasRunning, nil
|
||||
}
|
||||
|
||||
func snapshotBaseDir() string {
|
||||
return filepath.Join(config.AppConfig.DataDir, "snapshots")
|
||||
}
|
||||
|
||||
func copyTree(src string, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
|
||||
if err != nil {
|
||||
output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cp failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dirSizeBytes(path string) int64 {
|
||||
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) == 0 {
|
||||
return 0
|
||||
}
|
||||
var size int64
|
||||
fmt.Sscanf(parts[0], "%d", &size)
|
||||
return size
|
||||
}
|
||||
|
||||
func safePathUnder(path string, base string) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("refusing unsafe path: %s", absPath)
|
||||
}
|
||||
|
||||
func sortSnapshotsOldestFirst(snapshots []config.Snapshot) {
|
||||
sort.SliceStable(snapshots, func(i, j int) bool {
|
||||
ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt)
|
||||
tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt)
|
||||
return ti.Before(tj)
|
||||
})
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clicd/internal/api"
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
// webFS holds embedded frontend files
|
||||
@@ -18,12 +18,19 @@ var webFS http.FileSystem
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
@@ -32,6 +39,34 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedOrigin(origin string, requestHost string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
originHost := normalizeHost(u.Host)
|
||||
host := normalizeHost(requestHost)
|
||||
if originHost == host {
|
||||
return true
|
||||
}
|
||||
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// setupRoutes configures API and static routes
|
||||
func setupRoutes(mux *http.ServeMux) {
|
||||
// API routes
|
||||
@@ -50,10 +85,9 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
|
||||
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
|
||||
mux.HandleFunc("/api/oversell/reclaim", corsMiddleware(api.AdminMiddleware(api.HandleOversellReclaim)))
|
||||
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
||||
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
||||
@@ -61,18 +95,26 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
||||
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
|
||||
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
|
||||
mux.HandleFunc("/api/sub-users", corsMiddleware(api.AdminMiddleware(api.HandleSubUserList)))
|
||||
mux.HandleFunc("/api/sub-users/", corsMiddleware(api.AdminMiddleware(api.HandleSubUserAction)))
|
||||
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
|
||||
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
|
||||
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
|
||||
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
|
||||
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
|
||||
mux.HandleFunc("/api/security/settings", corsMiddleware(api.AdminMiddleware(api.HandleSecuritySettings)))
|
||||
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
||||
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
|
||||
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
||||
mux.HandleFunc("/api/vnc", api.HandleVNCProxy) // WebSocket
|
||||
|
||||
// API Key management
|
||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
||||
|
||||
// Version (public)
|
||||
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
||||
|
||||
// Static files
|
||||
if webFS != nil {
|
||||
fs := http.FileServer(webFS)
|
||||
@@ -107,7 +149,6 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
func Run() error {
|
||||
// Use embedded frontend files
|
||||
webFS = GetEmbeddedFS()
|
||||
startExpiryMonitor()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
setupRoutes(mux)
|
||||
@@ -123,16 +164,3 @@ func Run() error {
|
||||
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func startExpiryMonitor() {
|
||||
manager := lxc.NewManager()
|
||||
go func() {
|
||||
manager.StopExpiredContainers(time.Now())
|
||||
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for now := range ticker.C {
|
||||
manager.StopExpiredContainers(now)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.0"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
func Current() string {
|
||||
if Version == "" {
|
||||
return "dev"
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+15
-4
@@ -9,6 +9,7 @@ import (
|
||||
"clicd/internal/api"
|
||||
"clicd/internal/cli"
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
"clicd/internal/server"
|
||||
|
||||
@@ -50,15 +51,25 @@ func main() {
|
||||
// Start security scanner
|
||||
api.InitScanner()
|
||||
|
||||
// Ensure iptables FORWARD rules allow LXC traffic
|
||||
lxc.EnsureForwardRules()
|
||||
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||
lxc.EnsureForwardRules("lxcbr0")
|
||||
lxc.EnsureForwardRules("virbr0")
|
||||
|
||||
// Start expiry scanner (stops expired containers every 30s)
|
||||
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
||||
manager := lxc.NewManager()
|
||||
kvmManager := kvm.NewManager()
|
||||
manager.StartExpiryScanner()
|
||||
kvmManager.StartExpiryScanner()
|
||||
|
||||
// Start usage monitor (computes CPU/network/disk rates every 5s)
|
||||
// Start usage monitors (computes CPU/network/disk rates every 5s)
|
||||
manager.StartUsageMonitor()
|
||||
kvmManager.StartUsageMonitor()
|
||||
kvmManager.StartNetworkSyncMonitor()
|
||||
kvmManager.StartIPv6Guard()
|
||||
|
||||
// Start scheduled snapshot scanners.
|
||||
manager.StartSnapshotScheduler()
|
||||
kvmManager.StartSnapshotScheduler()
|
||||
|
||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||
config.CleanStaleContainers()
|
||||
|
||||
@@ -52,7 +52,8 @@ go mod tidy
|
||||
go mod download
|
||||
|
||||
# Build for Linux amd64
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o "$BUILD_DIR/clicd" .
|
||||
BUILD_VERSION="${CLICD_VERSION:-dev}"
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" .
|
||||
|
||||
echo "Go backend built successfully"
|
||||
|
||||
|
||||
+9
-1
@@ -5,8 +5,16 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CLICD - LXC Container Manager</title>
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('clicd_theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white text-black">
|
||||
<body class="bg-white text-black dark:bg-gray-950 dark:text-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+7
@@ -8,6 +8,7 @@
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.6.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
@@ -801,6 +802,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@novnc/novnc": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.6.0.tgz",
|
||||
"integrity": "sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.6.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
|
||||
+10
-4
@@ -4,12 +4,15 @@ import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Containers from './pages/Containers'
|
||||
import ContainerDetail from './pages/ContainerDetail'
|
||||
import Oversell from './pages/Oversell'
|
||||
|
||||
import Security from './pages/Security'
|
||||
import AuditLogs from './pages/AuditLogs'
|
||||
import ApiIntegration from './pages/ApiIntegration'
|
||||
import Settings from './pages/Settings'
|
||||
import ImageManagement from './pages/ImageManagement'
|
||||
import Snapshots from './pages/Snapshots'
|
||||
import Routing from './pages/Routing'
|
||||
import SubUserManagement from './pages/SubUserManagement'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -17,8 +20,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,10 +58,13 @@ function App() {
|
||||
<Route path="containers" element={<Containers />} />
|
||||
<Route path="images" element={<ImageManagement />} />
|
||||
<Route path="container/:id" element={<ContainerDetail />} />
|
||||
<Route path="oversell" element={<Oversell />} />
|
||||
|
||||
<Route path="security" element={<Security />} />
|
||||
<Route path="snapshots" element={<Snapshots />} />
|
||||
<Route path="routing" element={<Routing />} />
|
||||
<Route path="audit-logs" element={<AuditLogs />} />
|
||||
<Route path="api-integration" element={<ApiIntegration />} />
|
||||
<Route path="sub-users" element={<SubUserManagement />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -7,10 +7,12 @@ interface CreateContainerModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
|
||||
existingNames?: string[]
|
||||
}
|
||||
|
||||
const defaultForm: CreateContainerRequest = {
|
||||
name: '',
|
||||
virtualization: 'lxc',
|
||||
template_id: '',
|
||||
vcpu: 1,
|
||||
cpu_percent: 100,
|
||||
@@ -24,11 +26,12 @@ const defaultForm: CreateContainerRequest = {
|
||||
io_speed_mbps: 0,
|
||||
extra_ports: [],
|
||||
port_mapping_count: 2,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv6: false,
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) {
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||
const dialog = useDialog()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -36,17 +39,19 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
||||
const [nameError, setNameError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
getEnabledImages()
|
||||
getEnabledImages(form.virtualization)
|
||||
.then((res) => {
|
||||
const data = res.data.data || []
|
||||
setTemplates(data)
|
||||
if (data.length > 0) {
|
||||
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
|
||||
}
|
||||
setForm((prev) => {
|
||||
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
||||
return applyTemplateDefaults({ ...prev, template_id: templateID })
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
@@ -66,13 +71,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
getHostInfo()
|
||||
.then((res) => setHostInfo(res.data.data || null))
|
||||
.catch(() => setHostInfo(null))
|
||||
}, [isOpen])
|
||||
}, [isOpen, form.virtualization])
|
||||
|
||||
const ipv6Available = !!ipv6Status?.available
|
||||
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
|
||||
const autoPorts = useMemo(() => {
|
||||
const count = Math.max(2, form.port_mapping_count)
|
||||
@@ -82,19 +88,59 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||
const sshPortPreview = 22000
|
||||
|
||||
// Find next available batch index to avoid name conflicts
|
||||
const batchStartIndex = useMemo(() => {
|
||||
if (batchCount <= 1 || !form.name) return 1
|
||||
const prefix = `${form.name}-`
|
||||
let maxIdx = 0
|
||||
for (const existing of existingNames) {
|
||||
if (existing.startsWith(prefix)) {
|
||||
const suffix = existing.slice(prefix.length)
|
||||
const idx = parseInt(suffix, 10)
|
||||
if (!isNaN(idx) && idx > maxIdx) {
|
||||
maxIdx = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxIdx + 1
|
||||
}, [form.name, batchCount, existingNames])
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setForm({ ...form, name: value })
|
||||
if (/\s/.test(value)) {
|
||||
setNameError('容器名称不能包含空格')
|
||||
} else if (value && existingNames.includes(value) && batchCount === 1) {
|
||||
setNameError('该容器名称已存在')
|
||||
} else {
|
||||
setNameError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name || !form.template_id) {
|
||||
dialog.alert('提示', '请填写容器名称并选择系统模板')
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
if (Object.keys(resourceErrors).length > 0) {
|
||||
dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置')
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = normalizeCreateForm(form)
|
||||
|
||||
// Build batch of containers
|
||||
const containers: CreateContainerRequest[] = []
|
||||
const startIndex = batchStartIndex
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
|
||||
containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] })
|
||||
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
|
||||
containers.push({
|
||||
...boundedForm,
|
||||
name,
|
||||
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
|
||||
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||
extra_ports: [],
|
||||
})
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
@@ -130,27 +176,47 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
className={inputClass}
|
||||
onChange={(event) => handleNameChange(event.target.value)}
|
||||
className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`}
|
||||
placeholder="my-container"
|
||||
required
|
||||
/>
|
||||
{nameError && <p className="text-xs text-red-500 mt-1">{nameError}</p>}
|
||||
</Field>
|
||||
<Field label="批量创建数量">
|
||||
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
|
||||
</Field>
|
||||
</div>
|
||||
{batchCount > 1 && <p className="text-xs text-gray-400">将创建 {batchCount} 个容器:{form.name}-1 至 {form.name}-{batchCount}</p>}
|
||||
{batchCount > 1 && <p className="text-xs text-gray-400">将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}</p>}
|
||||
|
||||
<Field label="虚拟化架构">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
LXC 容器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
KVM 虚拟机
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="系统模板">
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
暂无可用的系统镜像,请先在「镜像管理」中下载镜像模板。
|
||||
暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={form.template_id}
|
||||
onChange={(event) => setForm({ ...form, template_id: event.target.value })}
|
||||
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
||||
className={inputClass}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
@@ -160,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
</Field>
|
||||
|
||||
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
@@ -180,16 +247,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="vCPU">
|
||||
<NumberInput value={form.vcpu} min={0.25} max={maxVCPU} step={0.25} onChange={(value) => setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} />
|
||||
<NumberInput
|
||||
value={form.vcpu}
|
||||
min={form.virtualization === 'kvm' ? 1 : 0.25}
|
||||
max={maxVCPU}
|
||||
step={form.virtualization === 'kvm' ? 1 : 0.25}
|
||||
invalid={!!resourceErrors.vcpu}
|
||||
onChange={(value) => setForm({ ...form, vcpu: value })}
|
||||
/>
|
||||
{resourceErrors.vcpu && <p className="mt-1 text-xs text-red-500">{resourceErrors.vcpu}</p>}
|
||||
</Field>
|
||||
<Field label="内存 (MB)">
|
||||
<NumberInput value={form.ram_mb} min={128} max={maxRAMMB} step={128} onChange={(value) => setForm({ ...form, ram_mb: clampInt(value, 128, maxRAMMB, 512) })} />
|
||||
<NumberInput
|
||||
value={form.ram_mb}
|
||||
min={128}
|
||||
max={maxRAMMB}
|
||||
step={128}
|
||||
invalid={!!resourceErrors.ram_mb}
|
||||
onChange={(value) => setForm({ ...form, ram_mb: value })}
|
||||
/>
|
||||
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="磁盘 (GB)">
|
||||
<NumberInput value={form.disk_gb} min={1} max={maxDiskGB} onChange={(value) => setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} />
|
||||
<NumberInput
|
||||
value={form.disk_gb}
|
||||
min={1}
|
||||
max={maxDiskGB}
|
||||
invalid={!!resourceErrors.disk_gb}
|
||||
onChange={(value) => setForm({ ...form, disk_gb: value })}
|
||||
/>
|
||||
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
|
||||
</Field>
|
||||
<Field label="带宽 (Mbps)">
|
||||
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
|
||||
@@ -238,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
SSH: {sshPortPreview} -> 22
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
@@ -248,6 +338,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.snapshot_limit}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="到期时间">
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
@@ -294,43 +393,115 @@ function NumberInput({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
invalid,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
invalid?: boolean
|
||||
onChange: (value: number) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '')
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) {
|
||||
setDraft(Number.isFinite(value) ? String(value) : '')
|
||||
}
|
||||
}, [focused, value])
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
type="text"
|
||||
inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
|
||||
value={draft}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false)
|
||||
setDraft(Number.isFinite(value) ? String(value) : '')
|
||||
}}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value
|
||||
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
|
||||
onChange(value)
|
||||
setDraft(raw)
|
||||
const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
|
||||
onChange(next)
|
||||
}}
|
||||
className={inputClass}
|
||||
aria-invalid={invalid || undefined}
|
||||
data-min={min}
|
||||
data-max={max}
|
||||
data-step={step}
|
||||
className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest {
|
||||
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
|
||||
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
|
||||
const windows = isWindowsTemplate(form.template_id)
|
||||
const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25)
|
||||
const minRAMMB = windows ? 2048 : 128
|
||||
const minDiskGB = windows ? 30 : 1
|
||||
|
||||
if (!Number.isFinite(form.vcpu)) {
|
||||
errors.vcpu = '请输入 vCPU'
|
||||
} else if (form.vcpu < minVCPU) {
|
||||
errors.vcpu = `不能小于 ${minVCPU} 核`
|
||||
} else if (form.vcpu > maxVCPU) {
|
||||
errors.vcpu = `不能大于 ${maxVCPU} 核`
|
||||
} else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) {
|
||||
errors.vcpu = 'KVM vCPU 必须是整数'
|
||||
}
|
||||
|
||||
if (!Number.isFinite(form.ram_mb)) {
|
||||
errors.ram_mb = '请输入内存'
|
||||
} else if (form.ram_mb < minRAMMB) {
|
||||
errors.ram_mb = `不能小于 ${minRAMMB} MB`
|
||||
} else if (maxRAMMB && form.ram_mb > maxRAMMB) {
|
||||
errors.ram_mb = `不能大于 ${maxRAMMB} MB`
|
||||
}
|
||||
|
||||
if (!Number.isFinite(form.disk_gb)) {
|
||||
errors.disk_gb = '请输入磁盘'
|
||||
} else if (form.disk_gb < minDiskGB) {
|
||||
errors.disk_gb = `不能小于 ${minDiskGB} GB`
|
||||
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
|
||||
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
const normalized = applyTemplateDefaults(form)
|
||||
return {
|
||||
...form,
|
||||
vcpu: clampVCPU(form.vcpu, maxVCPU),
|
||||
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
|
||||
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
|
||||
...normalized,
|
||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function clampVCPU(value: number, max: number) {
|
||||
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||
if (!isWindowsTemplate(form.template_id)) return form
|
||||
return {
|
||||
...form,
|
||||
virtualization: 'kvm',
|
||||
vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)),
|
||||
ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)),
|
||||
disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)),
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsTemplate(templateID: string) {
|
||||
return templateID.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
function normalizeLXCvCPU(value: number) {
|
||||
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
|
||||
return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2))
|
||||
return Number(rounded.toFixed(2))
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Layout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
|
||||
|
||||
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
|
||||
charts: ResourceChartConfig[]
|
||||
}) {
|
||||
return (
|
||||
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white">
|
||||
<h2 className="text-sm font-semibold text-gray-950">统计信息</h2>
|
||||
<section className="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
<h2 className="text-sm font-semibold text-gray-950 dark:text-white">统计信息</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5">
|
||||
<div className="inline-flex rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-0.5">
|
||||
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
onClick={() => onRangeChange(item)}
|
||||
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
|
||||
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900'
|
||||
range === item
|
||||
? 'bg-gray-800 text-white shadow-sm dark:bg-white dark:text-black'
|
||||
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{rangeLabels[item]}
|
||||
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
||||
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950">
|
||||
<span className="text-gray-500">{chart.icon}</span>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
|
||||
<span>{chart.title}</span>
|
||||
</div>
|
||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>}
|
||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-right">
|
||||
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
||||
@@ -115,8 +118,8 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
|
||||
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -132,6 +135,9 @@ function LineAreaChart({
|
||||
formatValue: (value: number) => string
|
||||
unitLabel?: string
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
const width = 520
|
||||
const height = 150
|
||||
const left = 50
|
||||
@@ -158,12 +164,20 @@ function LineAreaChart({
|
||||
const yTicks = [1, 0.5, 0]
|
||||
const xTicks = [0, 0.5, 1]
|
||||
|
||||
// Dark mode colors
|
||||
const gridStroke = isDark ? '#374151' : '#e5e7eb'
|
||||
const gridStrokeV = isDark ? '#1f2937' : '#edf0f2'
|
||||
const axisStroke = isDark ? '#9ca3af' : '#888'
|
||||
const lineStroke = isDark ? '#f9fafb' : '#444'
|
||||
const gradientTop = isDark ? '#f9fafb' : '#555'
|
||||
const gradientBottom = isDark ? '#374151' : '#555'
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#555" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#555" stopOpacity="0.02" />
|
||||
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
@@ -171,8 +185,8 @@ function LineAreaChart({
|
||||
const y = top + (1 - tick) * innerHeight
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" />
|
||||
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888">
|
||||
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke={gridStroke} strokeDasharray="3 3" />
|
||||
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
|
||||
{formatValue(maxValue * tick)}
|
||||
</text>
|
||||
</g>
|
||||
@@ -184,8 +198,8 @@ function LineAreaChart({
|
||||
const ts = minTs + tick * span
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" />
|
||||
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888">
|
||||
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke={gridStrokeV} strokeDasharray="3 3" />
|
||||
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
|
||||
{formatTime(ts)}
|
||||
</text>
|
||||
</g>
|
||||
@@ -193,15 +207,15 @@ function LineAreaChart({
|
||||
})}
|
||||
|
||||
{unitLabel && (
|
||||
<text x={left - 45} y={top + 10} fontSize="10" fill="#888">
|
||||
<text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
|
||||
{unitLabel}
|
||||
</text>
|
||||
)}
|
||||
|
||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" />
|
||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" />
|
||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
|
||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
|
||||
<polygon points={area} fill="url(#resource-chart-fill)" />
|
||||
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
interface RingStatProps {
|
||||
value: number
|
||||
@@ -10,10 +11,16 @@ interface RingStatProps {
|
||||
}
|
||||
|
||||
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
||||
const percentage = max === Infinity ? Math.max(value, 0) : Math.min(Math.max(value / max * 100, 0), 100)
|
||||
const strokeDashoffset = circumference - (Math.min(percentage, 100) / 100) * circumference
|
||||
|
||||
const bgStroke = isDark ? '#374151' : '#f3f4f6'
|
||||
const progressStroke = isDark ? '#f9fafb' : '#000000'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -25,7 +32,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#f3f4f6"
|
||||
stroke={bgStroke}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress ring */}
|
||||
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke={progressStroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
@@ -44,12 +51,12 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
||||
</svg>
|
||||
{/* Center value */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
|
||||
<span className="text-2xl font-bold text-black dark:text-white">{percentage.toFixed(percentage < 1 ? 2 : 1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<div className="text-sm font-medium text-gray-800">{label}</div>
|
||||
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>}
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
|
||||
{subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -66,7 +73,6 @@ interface RingStatsProps {
|
||||
swapUsed?: number
|
||||
swapTotal?: number
|
||||
loadPercent: number
|
||||
loadStatus: string
|
||||
diskPercent: number
|
||||
diskUsed: number
|
||||
diskTotal: number
|
||||
@@ -83,7 +89,6 @@ export default function RingStats({
|
||||
swapUsed = 0,
|
||||
swapTotal = 0,
|
||||
loadPercent,
|
||||
loadStatus,
|
||||
diskPercent,
|
||||
diskUsed,
|
||||
diskTotal,
|
||||
@@ -96,8 +101,8 @@ export default function RingStats({
|
||||
const hasSwap = swapTotal > 0
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black mb-4">状态</h2>
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black dark:text-white mb-4">状态</h2>
|
||||
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
|
||||
<RingStat
|
||||
value={cpuPercent}
|
||||
@@ -118,8 +123,8 @@ export default function RingStats({
|
||||
)}
|
||||
<RingStat
|
||||
value={loadPercent}
|
||||
max={Infinity}
|
||||
label="负载"
|
||||
subLabel={loadStatus}
|
||||
/>
|
||||
<RingStat
|
||||
value={diskPercent}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Camera,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
Package,
|
||||
Route,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings2,
|
||||
|
||||
ShieldAlert,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { getVersion } from '../services/api'
|
||||
import AppIcon from './AppIcon'
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -20,17 +27,48 @@ interface SidebarProps {
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function GitHubIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { logout, isSubUser } = useAuth()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const [version, setVersion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getVersion()
|
||||
.then(res => {
|
||||
if (res.data?.data?.version) {
|
||||
setVersion(res.data.data.version)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const isContainerPage =
|
||||
location.pathname.startsWith('/containers') ||
|
||||
location.pathname.startsWith('/container')
|
||||
|
||||
const isImagesPage = location.pathname.startsWith('/images')
|
||||
const isOversellPage = location.pathname.startsWith('/oversell')
|
||||
|
||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||
const isSecurityPage = location.pathname.startsWith('/security')
|
||||
@@ -38,27 +76,27 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${
|
||||
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 dark:bg-gray-900 dark:border-gray-700 ${
|
||||
collapsed ? 'w-16' : 'w-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center">
|
||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800">
|
||||
<AppIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-bold text-black text-sm">CLICD</span>
|
||||
<span className="font-bold text-black text-sm dark:text-white">CLICD</span>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto">
|
||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800">
|
||||
<AppIcon className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||
title="切换侧边栏"
|
||||
>
|
||||
{collapsed ? (
|
||||
@@ -75,8 +113,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
onClick={() => navigate('/')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
location.pathname === '/'
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
@@ -88,8 +126,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
onClick={() => navigate('/containers')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isContainerPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Server className="w-4 h-4" />
|
||||
@@ -101,8 +139,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
onClick={() => navigate('/images')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isImagesPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
@@ -112,48 +150,72 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
{!isSubUser && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate('/oversell')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isOversellPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
{!collapsed && <span>宿主机控制</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/security')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isSecurityPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
{!collapsed && <span>安全告警</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/snapshots')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isSnapshotsPage
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
{!collapsed && <span>快照管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/routing')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isRoutingPage
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Route className="w-4 h-4" />
|
||||
{!collapsed && <span>路由管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/audit-logs')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isAuditLogsPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ScrollText className="w-4 h-4" />
|
||||
{!collapsed && <span>操作日志</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/sub-users')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
location.pathname.startsWith('/sub-users')
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
{!collapsed && <span>子用户管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/api-integration')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isApiIntegrationPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
@@ -164,8 +226,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
onClick={() => navigate('/settings')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isSettingsPage
|
||||
? 'bg-black text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
@@ -175,10 +237,56 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-2">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
|
||||
{/* Version */}
|
||||
{version && (
|
||||
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
|
||||
{collapsed ? (
|
||||
<a
|
||||
href="https://github.com/MengMengCode/CLICD"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`CLICD v${version}`}
|
||||
className="inline-flex items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<a
|
||||
href="https://github.com/MengMengCode/CLICD"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="CLICD"
|
||||
className="inline-flex min-w-0 items-center gap-1 rounded text-gray-500 transition-colors hover:text-gray-950 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
<GitHubIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">CLICD</span>
|
||||
</a>
|
||||
<span className="shrink-0">v{version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{!collapsed && <span>退出登录</span>}
|
||||
|
||||
@@ -19,11 +19,10 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
|
||||
const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const buildWebSSHUrl = (ticket: string) => {
|
||||
const buildWebSSHUrl = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams({
|
||||
container: containerName,
|
||||
ticket,
|
||||
})
|
||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||
}
|
||||
@@ -106,7 +105,7 @@ export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerPro
|
||||
return
|
||||
}
|
||||
|
||||
const ws = new WebSocket(buildWebSSHUrl(ticket))
|
||||
const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`])
|
||||
ws.binaryType = 'arraybuffer'
|
||||
wsRef.current = ws
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFB from '@novnc/novnc'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFB | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const cleanup = () => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.disconnect()
|
||||
rfbRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const target = screenRef.current
|
||||
if (!target) return
|
||||
|
||||
cleanup()
|
||||
target.innerHTML = ''
|
||||
setStatus('connecting')
|
||||
setErrorMsg('')
|
||||
|
||||
let ticket = ''
|
||||
try {
|
||||
const response = await createVNCTicket(containerName)
|
||||
ticket = response.data.data?.ticket || ''
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setStatus('error')
|
||||
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
if (!ticket) {
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
||||
rfb.scaleViewport = true
|
||||
rfb.resizeSession = false
|
||||
rfb.focusOnClick = true
|
||||
rfb.qualityLevel = 6
|
||||
rfb.compressionLevel = 2
|
||||
rfb.background = '#050505'
|
||||
rfb.addEventListener('connect', () => {
|
||||
setStatus('connected')
|
||||
})
|
||||
rfb.addEventListener('disconnect', (event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||
if (detail && detail.clean === false) {
|
||||
setErrorMsg('WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用')
|
||||
}
|
||||
})
|
||||
rfb.addEventListener('securityfailure', () => {
|
||||
setStatus('error')
|
||||
setErrorMsg('VNC 安全协商失败')
|
||||
})
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
setStatus('error')
|
||||
setErrorMsg('当前 VNC 控制台要求密码,暂不支持自动输入')
|
||||
})
|
||||
rfbRef.current = rfb
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC 初始化失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(connect, 100)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
cleanup()
|
||||
}
|
||||
}, [containerName])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||
title="发送 Ctrl+Alt+Del"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
{status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,9 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (savedToken) {
|
||||
const payload = decodeTokenPayload(savedToken)
|
||||
const nextUsername = payload?.username || payload?.sub_user || savedUsername || null
|
||||
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0
|
||||
? payload.container_uuids
|
||||
: Array.isArray(payload?.container_names) ? payload.container_names : []
|
||||
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) ? payload.container_uuids : []
|
||||
|
||||
setToken(savedToken)
|
||||
setUsername(nextUsername)
|
||||
@@ -123,7 +121,6 @@ export function useAuth() {
|
||||
type TokenPayload = {
|
||||
username?: string
|
||||
sub_user?: string
|
||||
container_names?: string[]
|
||||
container_uuids?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => {} })
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
const stored = localStorage.getItem('clicd_theme') as Theme | null
|
||||
if (stored === 'dark' || stored === 'light') return stored
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('clicd_theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
+131
-3
@@ -14,19 +14,147 @@ body {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* ============ DARK MODE OVERRIDES ============ */
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #030712;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Background overrides */
|
||||
.dark .bg-white { background-color: #111827 !important; }
|
||||
.dark .bg-gray-50 { background-color: #030712 !important; }
|
||||
.dark .bg-gray-100 { background-color: #1f2937 !important; }
|
||||
.dark .bg-gray-200 { background-color: #374151 !important; }
|
||||
|
||||
/* Text overrides */
|
||||
.dark .text-black { color: #f9fafb !important; }
|
||||
.dark .text-gray-950 { color: #f9fafb !important; }
|
||||
.dark .text-gray-900 { color: #f3f4f6 !important; }
|
||||
.dark .text-gray-800 { color: #e5e7eb !important; }
|
||||
.dark .text-gray-700 { color: #d1d5db !important; }
|
||||
.dark .text-gray-600 { color: #9ca3af !important; }
|
||||
.dark .text-gray-500 { color: #9ca3af !important; }
|
||||
.dark .text-gray-400 { color: #6b7280 !important; }
|
||||
|
||||
/* Border overrides */
|
||||
.dark .border-gray-100 { border-color: #1f2937 !important; }
|
||||
.dark .border-gray-200 { border-color: #374151 !important; }
|
||||
.dark .border-gray-300 { border-color: #4b5563 !important; }
|
||||
|
||||
/* Divider overrides */
|
||||
.dark .divide-gray-50 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||
.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||
|
||||
/* Hover background overrides */
|
||||
.dark .hover\:bg-gray-50:hover { background-color: #1f2937 !important; }
|
||||
.dark .hover\:bg-gray-100:hover { background-color: #1f2937 !important; }
|
||||
.dark .hover\:bg-gray-200:hover { background-color: #374151 !important; }
|
||||
|
||||
/* Hover text overrides */
|
||||
.dark .hover\:text-black:hover { color: #f9fafb !important; }
|
||||
.dark .hover\:text-gray-900:hover { color: #f3f4f6 !important; }
|
||||
|
||||
/* Shadow */
|
||||
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
||||
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
||||
|
||||
/* bg-black buttons in dark mode -> light */
|
||||
.dark .bg-black { background-color: #f9fafb !important; }
|
||||
.dark .bg-black + span,
|
||||
.dark button.bg-black { color: #111827 !important; }
|
||||
.dark button.bg-black span { color: #111827 !important; }
|
||||
|
||||
/* Fix for CTA buttons (bg-black text-white) */
|
||||
.dark button.bg-black,
|
||||
.dark a.bg-black {
|
||||
background-color: #f9fafb !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Fix nested text-white inside bg-black in dark mode */
|
||||
.dark .bg-black .text-white,
|
||||
.dark .bg-black.text-white {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Invert sidebar active state */
|
||||
.dark button.bg-black.text-white,
|
||||
.dark button.bg-black > span {
|
||||
color: #111827 !important;
|
||||
}
|
||||
.dark button.bg-black svg {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Hover: bg-gray-800 in dark mode */
|
||||
.dark .hover\:bg-gray-800:hover { background-color: #e5e7eb !important; color: #111827 !important; }
|
||||
|
||||
/* Status badge backgrounds */
|
||||
.dark .bg-green-50 { background-color: #064e3b !important; }
|
||||
.dark .bg-red-50 { background-color: #450a0a !important; }
|
||||
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||
|
||||
/* Status badge text */
|
||||
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||
.dark .text-red-600 { color: #fca5a5 !important; }
|
||||
.dark .text-red-700 { color: #fca5a5 !important; }
|
||||
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
||||
|
||||
/* Focus ring */
|
||||
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||
.dark .focus\:border-black:focus { border-color: #f9fafb !important; }
|
||||
|
||||
/* Accent */
|
||||
.dark .accent-black { accent-color: #f9fafb !important; }
|
||||
|
||||
/* Spinner */
|
||||
.dark .border-black { border-color: #f9fafb !important; }
|
||||
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||
.dark .border-t-black { border-top-color: #f9fafb !important; }
|
||||
.dark .animate-spin.rounded-full { border-color: #f9fafb !important; border-bottom-color: transparent !important; }
|
||||
|
||||
/* Placeholder */
|
||||
.dark .placeholder-gray-400::placeholder { color: #6b7280 !important; }
|
||||
|
||||
/* Success/Error text standalone */
|
||||
.dark .text-green-600 { color: #6ee7b7 !important; }
|
||||
|
||||
/* Modal backdrop */
|
||||
.dark .bg-black\/50 { background-color: rgba(0,0,0,0.7) !important; }
|
||||
|
||||
/* Toggle / switch */
|
||||
.dark .bg-gray-300 { background-color: #4b5563 !important; }
|
||||
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
|
||||
.dark .peer-checked\:bg-black:checked + *,
|
||||
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
|
||||
|
||||
|
||||
@@ -3,17 +3,20 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { DialogProvider } from './components/Dialog'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare module '@novnc/novnc' {
|
||||
export default class RFB extends EventTarget {
|
||||
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
|
||||
scaleViewport: boolean
|
||||
resizeSession: boolean
|
||||
focusOnClick: boolean
|
||||
viewOnly: boolean
|
||||
qualityLevel: number
|
||||
compressionLevel: number
|
||||
background: string
|
||||
disconnect(): void
|
||||
sendCtrlAltDel(): void
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
|
||||
import api, { APIResponse } from '../services/api'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
interface ApiKeyItem {
|
||||
id: string
|
||||
@@ -62,21 +63,12 @@ export default function ApiIntegration() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const copyKey = () => {
|
||||
try {
|
||||
navigator.clipboard.writeText(newKey)
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = newKey
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
const copyKey = async () => {
|
||||
const copied = await copyToClipboard(newKey)
|
||||
if (copied) {
|
||||
setCopiedKey(true)
|
||||
setTimeout(() => setCopiedKey(false), 2000)
|
||||
}
|
||||
setCopiedKey(true)
|
||||
setTimeout(() => setCopiedKey(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -256,10 +248,7 @@ export default function ApiIntegration() {
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="font-semibold text-black mb-2">超售 & 批量</h3>
|
||||
<Endpoint method="POST" path="/api/oversell" desc="获取/更新超售配置" body='{"cpu_overcommit": 4, "ram_overcommit": 2, "disk_overcommit": 1, "ksm_enabled": true, "swappiness": 10}' />
|
||||
<Endpoint method="POST" path="/api/oversell/reclaim" desc="触发一次内存回收" />
|
||||
<Endpoint method="POST" path="/api/oversell/status" desc="超售状态" />
|
||||
<h3 className="font-semibold text-black mb-2">批量操作</h3>
|
||||
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
|
||||
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
|
||||
</section>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
Clock,
|
||||
Copy,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Key,
|
||||
Maximize2,
|
||||
MemoryStick,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
Network,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -29,9 +35,12 @@ import {
|
||||
Container,
|
||||
ContainerUsage,
|
||||
createSubUser,
|
||||
createContainerSnapshot,
|
||||
deleteContainer,
|
||||
deleteContainerSnapshot,
|
||||
deletePortMapping,
|
||||
getContainer,
|
||||
getContainerSnapshots,
|
||||
getContainerUsage,
|
||||
getHostInfo,
|
||||
getTrafficInfo,
|
||||
@@ -44,8 +53,13 @@ import {
|
||||
restartContainer,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
Snapshot,
|
||||
SnapshotSchedule,
|
||||
Template,
|
||||
updateContainerExpiry,
|
||||
updateSnapshotQuota,
|
||||
updateSnapshotSchedule,
|
||||
restoreContainerSnapshot,
|
||||
resetTraffic,
|
||||
updateTrafficLimit,
|
||||
updateResourceLimit,
|
||||
@@ -55,7 +69,9 @@ import {
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import WebSSHViewer from '../components/WebSSHViewer'
|
||||
import WebVNCViewer from '../components/WebVNCViewer'
|
||||
import { RingStat } from '../components/RingStats'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
import ResourceStatsPanel, {
|
||||
ChartPoint,
|
||||
ResourceChartConfig,
|
||||
@@ -104,6 +120,9 @@ export default function ContainerDetail() {
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [taskStatus, setTaskStatus] = useState('') // current task type for this container
|
||||
const [showSSH, setShowSSH] = useState(false)
|
||||
const [showVNC, setShowVNC] = useState(false)
|
||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||
const [showNat, setShowNat] = useState(false)
|
||||
const [showNatAdd, setShowNatAdd] = useState(false)
|
||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||
@@ -125,6 +144,15 @@ export default function ContainerDetail() {
|
||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
|
||||
const [savingResource, setSavingResource] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showSnapshots, setShowSnapshots] = useState(false)
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [snapshotQuota, setSnapshotQuota] = useState(3)
|
||||
const [snapshotQuotaDraft, setSnapshotQuotaDraft] = useState(3)
|
||||
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||
|
||||
const fetchContainer = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
@@ -142,18 +170,36 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}, [containerIdentifier, isSubUser])
|
||||
|
||||
const fetchSnapshots = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
try {
|
||||
const res = await getContainerSnapshots(containerIdentifier)
|
||||
const data = res.data.data
|
||||
const quota = data?.quota || container?.snapshot_limit || 3
|
||||
setSnapshots(data?.snapshots || [])
|
||||
setSnapshotQuota(quota)
|
||||
setSnapshotQuotaDraft(quota)
|
||||
setSnapshotSchedule(data?.schedule || null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch snapshots:', err)
|
||||
}
|
||||
}, [containerIdentifier, container?.snapshot_limit])
|
||||
|
||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
||||
if (!containerIdentifier || !currentContainer) return
|
||||
|
||||
const memoryPct = currentContainer.ram_mb > 0
|
||||
? (nextUsage.memory_usage_bytes / (currentContainer.ram_mb * 1024 * 1024)) * 100
|
||||
const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
|
||||
? nextUsage.memory_total_bytes
|
||||
: currentContainer.ram_mb * 1024 * 1024
|
||||
const memoryPct = memoryTotalBytes > 0
|
||||
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
||||
: 0
|
||||
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
||||
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
|
||||
|
||||
const point: MetricPoint = {
|
||||
ts: Date.now(),
|
||||
cpu: clamp(nextUsage.cpu_usage_pct || 0),
|
||||
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
||||
memory: clamp(memoryPct),
|
||||
network: networkBps,
|
||||
diskIO: diskIOBps,
|
||||
@@ -192,12 +238,38 @@ export default function ContainerDetail() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [fetchContainer])
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setVncFullscreen(document.fullscreenElement === vncFullscreenRef.current)
|
||||
}
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const toggleVNCFullscreen = async () => {
|
||||
const target = vncFullscreenRef.current
|
||||
if (!target) return
|
||||
try {
|
||||
if (document.fullscreenElement === target) {
|
||||
await document.exitFullscreen()
|
||||
} else {
|
||||
await target.requestFullscreen()
|
||||
}
|
||||
} catch {
|
||||
setVncFullscreen((value) => !value)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsage()
|
||||
const timer = window.setInterval(fetchUsage, 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [fetchUsage])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSnapshots) fetchSnapshots()
|
||||
}, [showSnapshots, fetchSnapshots])
|
||||
|
||||
// Poll task status for this container
|
||||
useEffect(() => {
|
||||
if (!containerIdentifier) return
|
||||
@@ -228,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) {
|
||||
@@ -239,6 +320,7 @@ export default function ContainerDetail() {
|
||||
case 'stop':
|
||||
await stopContainer(containerIdentifier)
|
||||
setShowSSH(false)
|
||||
setShowVNC(false)
|
||||
break
|
||||
case 'restart':
|
||||
await restartContainer(containerIdentifier)
|
||||
@@ -320,7 +402,7 @@ export default function ContainerDetail() {
|
||||
|
||||
const openReinstall = async () => {
|
||||
try {
|
||||
const res = await getEnabledImages()
|
||||
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
||||
if (res.data.data) {
|
||||
setTemplates(res.data.data)
|
||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
||||
@@ -351,6 +433,7 @@ export default function ContainerDetail() {
|
||||
await reinstallContainer(containerIdentifier, selectedTemplate)
|
||||
setShowReinstall(false)
|
||||
setShowSSH(false)
|
||||
setShowVNC(false)
|
||||
await fetchContainer()
|
||||
} catch (err) {
|
||||
console.error('Reinstall failed:', err)
|
||||
@@ -389,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({
|
||||
@@ -416,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)
|
||||
@@ -468,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()
|
||||
@@ -478,20 +565,115 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await copyText(text)
|
||||
} catch {
|
||||
// Fallback for HTTP (non-secure context)
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
const handleCreateSnapshot = async () => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||
return
|
||||
}
|
||||
if (container?.status === 'running') {
|
||||
const confirmed = await dialog.confirm(
|
||||
'拍摄快照',
|
||||
`拍摄快照需要先关机,完成后会自动重启容器 ${container.name}。是否继续?`
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
setSnapshotBusy('create')
|
||||
try {
|
||||
await createContainerSnapshot(containerIdentifier)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('创建快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const openSnapshotSchedule = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
setSnapshotScheduleDraft({
|
||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||
time: snapshotSchedule?.time || '03:00',
|
||||
})
|
||||
setShowSnapshotSchedule(true)
|
||||
}
|
||||
|
||||
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) {
|
||||
await dialog.alert('参数错误', '自动快照周期最低是 1 天一次。')
|
||||
return
|
||||
}
|
||||
setSnapshotBusy('schedule')
|
||||
try {
|
||||
await updateSnapshotSchedule(containerIdentifier, enabled, intervalHours, scheduleTime)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
setShowSnapshotSchedule(false)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('定时快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const saveSnapshotQuota = async () => {
|
||||
if (!containerIdentifier || isSubUser) return
|
||||
const nextQuota = Math.max(1, Math.round(snapshotQuotaDraft || 1))
|
||||
setSnapshotBusy('quota')
|
||||
try {
|
||||
await updateSnapshotQuota(containerIdentifier, nextQuota)
|
||||
setSnapshotQuota(nextQuota)
|
||||
setSnapshotQuotaDraft(nextQuota)
|
||||
setEditingSnapshotQuota(false)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('保存快照配额失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
|
||||
setSnapshotBusy(snapshot.id)
|
||||
try {
|
||||
await deleteContainerSnapshot(containerIdentifier, snapshot.id)
|
||||
await fetchSnapshots()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('删除快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
|
||||
setSnapshotBusy(snapshot.id)
|
||||
try {
|
||||
await restoreContainerSnapshot(containerIdentifier, snapshot.id)
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
await dialog.alert('恢复快照失败', error.response?.data?.message || '请稍后重试。')
|
||||
} finally {
|
||||
setSnapshotBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
await copyToClipboard(text)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -514,17 +696,28 @@ export default function ContainerDetail() {
|
||||
}
|
||||
|
||||
const isRunning = container.status === 'running'
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
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
|
||||
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
|
||||
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22
|
||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
|
||||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
|
||||
)
|
||||
const filtered = filterHistory(history, range)
|
||||
const cpuPct = clamp(usage?.cpu_usage_pct || 0)
|
||||
const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0
|
||||
const cpuPct = clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1)))
|
||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||
? usage.memory_total_bytes
|
||||
: container.ram_mb * 1024 * 1024
|
||||
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
|
||||
const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0
|
||||
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
|
||||
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
|
||||
const rx = usage?.network_rx_bps || 0
|
||||
@@ -532,16 +725,15 @@ 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)}`
|
||||
: ''
|
||||
const managementPassword = subUser?.password || ''
|
||||
const charts: ResourceChartConfig[] = [
|
||||
{
|
||||
title: 'CPU 使用率',
|
||||
icon: <Cpu className="w-5 h-5" />,
|
||||
current: usage?.cpu_usage_pct || 0,
|
||||
current: clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))),
|
||||
points: toChartPoints(filtered, 'cpu'),
|
||||
max: 100,
|
||||
formatValue: formatPercent,
|
||||
@@ -554,7 +746,7 @@ export default function ContainerDetail() {
|
||||
points: toChartPoints(filtered, 'memory'),
|
||||
max: 100,
|
||||
formatValue: formatPercent,
|
||||
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${container.ram_mb} MB`,
|
||||
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${formatBytes(ramTotalBytes)}`,
|
||||
},
|
||||
{
|
||||
title: '网络流量',
|
||||
@@ -600,33 +792,43 @@ export default function ContainerDetail() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
||||
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
||||
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
||||
<InfoTag color="violet">{publicHost}:{container.ssh_port}</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'] : '重启'}
|
||||
</ActionButton>
|
||||
<ActionButton dark onClick={() => setShowSSH(true)}>
|
||||
<TerminalSquare className="w-3.5 h-3.5" />
|
||||
WebSSH
|
||||
{isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
|
||||
</ActionButton>
|
||||
{!isWindows && (
|
||||
<ActionButton dark disabled={isSubUserPolicyBlocked} onClick={() => setShowSSH(true)}>
|
||||
<TerminalSquare className="w-3.5 h-3.5" />
|
||||
WebSSH
|
||||
</ActionButton>
|
||||
)}
|
||||
{isKVM && (
|
||||
<ActionButton dark disabled={!canOpenVNC || isSubUserPolicyBlocked} onClick={() => setShowVNC(true)}>
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
WebVNC
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isSubUser && (
|
||||
@@ -636,11 +838,15 @@ 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 || isSubUserPolicyBlocked}>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
快照
|
||||
</ActionButton>
|
||||
{!isSubUser && (
|
||||
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
@@ -657,32 +863,75 @@ 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="连接信息">
|
||||
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
||||
<PlainRow label="用户名" value="root" mono />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">SSH 密码</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{showPassword ? (container.ssh_password || '-') : '••••••••'}
|
||||
</span>
|
||||
{container.ssh_password && (
|
||||
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
{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 />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">管理员密码</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
|
||||
</span>
|
||||
{container.ssh_password && (
|
||||
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{container.vnc_port > 0 && (
|
||||
<PlainRow label="VNC 端口" value={`127.0.0.1:${container.vnc_port}`} mono />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
||||
<PlainRow label="用户名" value="root" mono />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">SSH 密码</span>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span
|
||||
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||
>
|
||||
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
|
||||
</span>
|
||||
{container.ssh_password && (
|
||||
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isSubUser && (
|
||||
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
|
||||
<Key className="w-3 h-3" />
|
||||
重置 SSH 密码
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isSubUser && (
|
||||
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
|
||||
<Key className="w-3 h-3" />
|
||||
重置 SSH 密码
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -737,12 +986,12 @@ export default function ContainerDetail() {
|
||||
<RingStat
|
||||
value={ramPct}
|
||||
label="内存"
|
||||
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(container.ram_mb * 1024 * 1024)}`}
|
||||
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(ramTotalBytes)}`}
|
||||
/>
|
||||
<RingStat
|
||||
value={Math.min(cpuPct, 100)}
|
||||
value={loadPct}
|
||||
max={Infinity}
|
||||
label="负载"
|
||||
subLabel={cpuPct < 70 ? '正常' : cpuPct < 90 ? '中等' : '高'}
|
||||
/>
|
||||
<RingStat
|
||||
value={diskPct}
|
||||
@@ -846,6 +1095,203 @@ export default function ContainerDetail() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showVNC && (
|
||||
<Modal
|
||||
title={`WebVNC - ${container.name}`}
|
||||
onClose={() => setShowVNC(false)}
|
||||
wide
|
||||
flush
|
||||
extra={
|
||||
<button
|
||||
onClick={toggleVNCFullscreen}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-gray-600 hover:text-black hover:bg-gray-100 rounded"
|
||||
title={vncFullscreen ? '退出全屏' : '全屏显示'}
|
||||
>
|
||||
{vncFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||||
{vncFullscreen ? '退出全屏' : '全屏'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={vncFullscreenRef}
|
||||
className={`bg-white ${vncFullscreen ? 'fixed inset-0 z-[70] p-3' : 'h-[calc(92vh-112px)] min-h-[420px] p-5'}`}
|
||||
>
|
||||
<div className="h-full">
|
||||
{canOpenVNC ? (
|
||||
<WebVNCViewer containerName={container.name} onClose={() => setShowVNC(false)} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center bg-gray-950 text-gray-400 rounded-md">VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSnapshots && (
|
||||
<Modal
|
||||
title="快照"
|
||||
onClose={() => {
|
||||
setShowSnapshots(false)
|
||||
setEditingSnapshotQuota(false)
|
||||
}}
|
||||
wide
|
||||
extra={
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSnapshotSchedule}
|
||||
disabled={!!snapshotBusy}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
||||
snapshotSchedule?.enabled
|
||||
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{snapshotBusy === 'schedule' ? '处理中...' : snapshotSchedule?.enabled ? '定时设置' : '定时快照'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSnapshot}
|
||||
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
{snapshotBusy === 'create' ? '创建中...' : '新建快照'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
|
||||
<div>
|
||||
快照数量:
|
||||
<span className="font-mono text-gray-900">
|
||||
{snapshots.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>子用户配额:</span>
|
||||
<span className="font-mono text-gray-900">{snapshotQuota}</span>
|
||||
{!isSubUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSnapshotQuotaDraft(snapshotQuota)
|
||||
setEditingSnapshotQuota((value) => !value)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded border border-gray-300 bg-white px-2 py-1 text-[11px] text-gray-700 hover:bg-gray-50"
|
||||
disabled={snapshotBusy === 'quota'}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
修改
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
定时状态:
|
||||
<span className="text-gray-900">
|
||||
{snapshotSchedule?.enabled ? `已开启,每 ${formatScheduleInterval(snapshotSchedule.interval_hours || 24)},${snapshotSchedule.time || '03:00'} 执行` : '未开启'}
|
||||
</span>
|
||||
</div>
|
||||
{snapshotSchedule?.next_run && (
|
||||
<div>下次执行:<span className="font-mono text-gray-900">{formatDateTime(snapshotSchedule.next_run)}</span></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingSnapshotQuota && !isSubUser && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<Field label="子用户每台容器快照上限">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={snapshotQuotaDraft}
|
||||
onChange={(event) => setSnapshotQuotaDraft(Math.max(1, Math.round(Number(event.target.value) || 1)))}
|
||||
className="w-44 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2 pb-0.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSnapshotQuota(false)
|
||||
setSnapshotQuotaDraft(snapshotQuota)
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||
disabled={snapshotBusy === 'quota'}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSnapshotQuota}
|
||||
disabled={snapshotBusy === 'quota'}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{snapshotBusy === 'quota' ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SnapshotTable
|
||||
snapshots={snapshots}
|
||||
busy={snapshotBusy}
|
||||
onRestore={handleRestoreSnapshot}
|
||||
onDelete={handleDeleteSnapshot}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSnapshotSchedule && (
|
||||
<Modal title="定时快照" onClose={() => setShowSnapshotSchedule(false)}>
|
||||
<div className="space-y-4">
|
||||
<Field label="自动快照周期">
|
||||
<select
|
||||
value={snapshotScheduleDraft.intervalHours}
|
||||
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, intervalHours: Number(e.target.value) })}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value={24}>1 天</option>
|
||||
<option value={72}>3 天</option>
|
||||
<option value={168}>7 天</option>
|
||||
<option value={336}>14 天</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="执行时间">
|
||||
<input
|
||||
type="time"
|
||||
value={snapshotScheduleDraft.time}
|
||||
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, time: e.target.value || '03:00' })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||
{`每 ${formatScheduleInterval(snapshotScheduleDraft.intervalHours)} 在 ${snapshotScheduleDraft.time || '03:00'} 执行。`}
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 pt-2">
|
||||
{snapshotSchedule?.enabled ? (
|
||||
<button
|
||||
onClick={() => saveSnapshotSchedule(false)}
|
||||
disabled={snapshotBusy === 'schedule'}
|
||||
className="px-4 py-2 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
关闭定时
|
||||
</button>
|
||||
) : <div />}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowSnapshotSchedule(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md">取消</button>
|
||||
<button
|
||||
onClick={() => saveSnapshotSchedule(true)}
|
||||
disabled={snapshotBusy === 'schedule'}
|
||||
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{snapshotBusy === 'schedule' ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showNat && (
|
||||
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && !showNatAdd && (
|
||||
@@ -954,55 +1400,21 @@ export default function ContainerDetail() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSubUser && subUser && false && (
|
||||
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
|
||||
安全提示:请通过私密渠道(如加密通讯工具)分享以下信息,不要在不安全的网络环境下明文传输。
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500">管理地址</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
|
||||
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500">用户名</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="font-mono text-xs text-black">{subUser?.username}</span>
|
||||
<button onClick={() => copyText(subUser?.username || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500">密码</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="font-mono text-xs text-black">{managementPassword}</span>
|
||||
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">打开管理地址,输入用户名和密码即可管理该容器。链接不含 token,无法被截获后直接使用。</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showSubUser && subUser && (
|
||||
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500">地址</span>
|
||||
<span className="shrink-0 text-gray-500 dark:text-gray-400">地址</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
|
||||
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
||||
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl}</span>
|
||||
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500">密码</span>
|
||||
<span className="shrink-0 text-gray-500 dark:text-gray-400">密码</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="font-mono text-xs text-black">{managementPassword}</span>
|
||||
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
||||
<span className="font-mono text-xs text-black dark:text-white">{subUser.password || ''}</span>
|
||||
<button onClick={() => copyText(subUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1132,12 +1544,14 @@ function StatusBadge({ running }: { running: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet'; 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>
|
||||
}
|
||||
@@ -1229,6 +1643,65 @@ function PlainRow({ label, value, mono = false, copyValue, onCopy, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotTable({ snapshots, busy, onRestore, onDelete }: {
|
||||
snapshots: Snapshot[]
|
||||
busy: string
|
||||
onRestore: (snapshot: Snapshot) => void
|
||||
onDelete: (snapshot: Snapshot) => void
|
||||
}) {
|
||||
if (snapshots.length === 0) {
|
||||
return <p className="rounded-lg border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-400">暂无快照</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<TableHead>快照时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>创建者</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id}>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{snapshot.created_at}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{snapshot.scheduled ? '定时' : '手动'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{snapshot.created_by || '-'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => onRestore(snapshot)}
|
||||
disabled={!!busy}
|
||||
className="rounded border border-gray-300 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{busy === snapshot.id ? '处理中...' : '恢复'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(snapshot)}
|
||||
disabled={!!busy}
|
||||
className="rounded border border-red-200 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, isSubUser = false }: { mappings: PortMapping[]; publicHost: string; onEdit: (pm: PortMapping, index: number) => void; onDelete: (index: number) => void; compact?: boolean; isSubUser?: boolean }) {
|
||||
if (mappings.length === 0) {
|
||||
return <p className="text-sm text-gray-400">暂无端口映射</p>
|
||||
@@ -1248,7 +1721,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{mappings.map((pm, index) => {
|
||||
const isSSH = pm.description === 'SSH' || pm.container_port === 22
|
||||
const isSSH = pm.description === 'SSH' || pm.container_port === 22 || pm.description === 'RDP' || pm.container_port === 3389
|
||||
return (
|
||||
<tr key={`${pm.host_port}-${pm.container_port}-${index}`}>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
@@ -1294,7 +1767,7 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Modal({ title, children, onClose, wide = false, extra }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode }) {
|
||||
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}>
|
||||
@@ -1307,7 +1780,7 @@ function Modal({ title, children, onClose, wide = false, extra }: { title: strin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 overflow-y-auto">{children}</div>
|
||||
<div className={flush ? "overflow-hidden" : "p-5 overflow-y-auto"}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1389,6 +1862,19 @@ function formatExpiration(value?: string): string {
|
||||
return value.length >= 10 ? value.slice(0, 10) : value
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string): string {
|
||||
if (!value) return '-'
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
|
||||
function formatScheduleInterval(hours: number): string {
|
||||
if (hours === 24) return '1 天'
|
||||
if (hours % 24 === 0) return `${hours / 24} 天`
|
||||
return `${hours} 小时`
|
||||
}
|
||||
|
||||
function formatRate(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
|
||||
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||
@@ -1469,6 +1955,7 @@ function TrafficBar({ container }: { container: Container }) {
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-6 h-6'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||
@@ -1478,5 +1965,6 @@ function getTemplateIcon(id: string): ReactNode {
|
||||
if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg>
|
||||
if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg>
|
||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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,12 @@ 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 [typeFilter, setTypeFilter] = useState('all')
|
||||
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 +109,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 +165,45 @@ 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,
|
||||
type: typeFilter,
|
||||
system: systemFilter,
|
||||
status: statusFilter,
|
||||
taskStatusMap,
|
||||
taskNameMap,
|
||||
})
|
||||
}, [displayContainers, searchText, typeFilter, 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, typeFilter, 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,29 +227,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">
|
||||
{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>
|
||||
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={handleRefreshList}
|
||||
disabled={refreshing}
|
||||
@@ -249,6 +270,84 @@ export default function Containers() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayContainers.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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={typeFilter}
|
||||
onChange={(event) => setTypeFilter(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="lxc">LXC</option>
|
||||
<option value="kvm">KVM</option>
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<span className="text-xs text-gray-500">{selected.size} 个</span>
|
||||
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
|
||||
</button>
|
||||
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayContainers.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
@@ -260,14 +359,15 @@ export default function Containers() {
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px]">
|
||||
<table className="w-full min-w-[1260px]">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="w-10 px-3 py-3">
|
||||
{!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"
|
||||
/>
|
||||
@@ -277,6 +377,7 @@ export default function Containers() {
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>系统</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead>
|
||||
<TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
|
||||
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
|
||||
@@ -287,15 +388,22 @@ 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
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const usage = usageByName[container.name]
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
|
||||
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0
|
||||
const ramPct = isRunning && container.ram_mb > 0
|
||||
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100)
|
||||
const cpuPct = isRunning
|
||||
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
|
||||
: 0
|
||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||
? usage.memory_total_bytes
|
||||
: container.ram_mb * 1024 * 1024
|
||||
const ramPct = isRunning && ramTotalBytes > 0
|
||||
? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100)
|
||||
: 0
|
||||
const diskPct = container.disk_gb > 0
|
||||
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
|
||||
@@ -329,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">
|
||||
@@ -337,6 +445,9 @@ export default function Containers() {
|
||||
{getTemplateName(container.template)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top">
|
||||
<RuntimeBadge runtime={container.virtualization || 'lxc'} />
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top">
|
||||
<ProgressCell pct={cpuPct} />
|
||||
</td>
|
||||
@@ -398,10 +509,54 @@ 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>
|
||||
)}
|
||||
|
||||
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} />
|
||||
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} />
|
||||
{showTasks && (
|
||||
<TaskQueueModal
|
||||
tasks={tasks}
|
||||
@@ -426,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`}>
|
||||
@@ -484,6 +648,15 @@ function StatusBadge({ running, task, placeholder }: { running: boolean; task?:
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeBadge({ runtime }: { runtime: string }) {
|
||||
const normalized = runtime === 'kvm' ? 'kvm' : 'lxc'
|
||||
return (
|
||||
<span className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${normalized === 'kvm' ? 'bg-indigo-50 text-indigo-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{normalized.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDisplayContainers(
|
||||
containers: Container[],
|
||||
queuedCreates: Record<string, CreateContainerRequest>,
|
||||
@@ -515,6 +688,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
id: 0,
|
||||
uuid: '',
|
||||
name: cfg.name,
|
||||
virtualization: cfg.virtualization || 'lxc',
|
||||
template: cfg.template_id,
|
||||
vcpu: cfg.vcpu,
|
||||
ram_mb: cfg.ram_mb,
|
||||
@@ -538,8 +712,15 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
ssh_password: '',
|
||||
port_mappings: [],
|
||||
port_mapping_limit: 2,
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
expires_at: cfg.expires_at,
|
||||
snapshot_schedule_enabled: false,
|
||||
snapshot_schedule_interval_hours: 24,
|
||||
snapshot_schedule_time: '03:00',
|
||||
snapshot_schedule_last_run: '',
|
||||
snapshot_schedule_next_run: '',
|
||||
snapshot_schedule_created_by: '',
|
||||
isPlaceholder: true,
|
||||
}
|
||||
}
|
||||
@@ -578,6 +759,92 @@ function hasActiveTasks(tasks: Task[]) {
|
||||
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
|
||||
}
|
||||
|
||||
type ContainerFilters = {
|
||||
search: string
|
||||
type: 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.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) {
|
||||
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,
|
||||
container.virtualization || 'lxc',
|
||||
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) {
|
||||
const normalized = template.startsWith('kvm-') ? template.slice(4) : template
|
||||
if (normalized.startsWith('ubuntu')) return 'ubuntu'
|
||||
if (normalized.startsWith('debian')) return 'debian'
|
||||
if (normalized.startsWith('alpine')) return 'alpine'
|
||||
if (normalized.startsWith('centos')) return 'centos'
|
||||
if (normalized.startsWith('archlinux')) return 'archlinux'
|
||||
if (normalized.startsWith('fedora')) return 'fedora'
|
||||
if (normalized.startsWith('rockylinux')) return 'rockylinux'
|
||||
if (normalized.startsWith('windows')) return 'windows'
|
||||
return normalized || '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',
|
||||
windows: 'Windows',
|
||||
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 '初始化完成'
|
||||
@@ -690,12 +957,19 @@ function getTemplateName(id: string) {
|
||||
'archlinux-current': 'Arch Linux',
|
||||
'fedora-44': 'Fedora 44',
|
||||
'rockylinux-10': 'Rocky 10',
|
||||
'kvm-ubuntu-noble': 'Ubuntu 24.04',
|
||||
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
|
||||
'kvm-debian-bookworm': 'Debian 12',
|
||||
'kvm-debian-bullseye': 'Debian 11',
|
||||
'kvm-rockylinux-9': 'Rocky 9',
|
||||
'kvm-windows-10': 'Windows 10',
|
||||
}
|
||||
return map[id] || id
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-4 h-4'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||
@@ -703,6 +977,7 @@ function getTemplateIcon(id: string): ReactNode {
|
||||
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
||||
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,7 @@ export default function Dashboard() {
|
||||
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
|
||||
ramUsed={host.ram.used_mb}
|
||||
ramTotal={host.ram.total_mb}
|
||||
loadPercent={Math.min((host.load.load1 / host.cpu.cores) * 100, 100)}
|
||||
loadStatus={host.load.load1 < host.cpu.cores * 0.7 ? '正常' : host.load.load1 < host.cpu.cores * 1.0 ? '中等' : '高'}
|
||||
loadPercent={(host.load.load1 / Math.max(host.cpu.cores, 1)) * 100}
|
||||
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
|
||||
diskUsed={host.disk.used_gb * 1024}
|
||||
diskTotal={host.disk.total_gb * 1024}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
|
||||
export default function ImageManagement() {
|
||||
const dialog = useDialog()
|
||||
const [images, setImages] = useState<ImageInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
@@ -43,15 +45,14 @@ export default function ImageManagement() {
|
||||
await downloadImage(templateId)
|
||||
await fetchImages()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '下载失败'
|
||||
setError(msg)
|
||||
setError(apiErrorMessage(err, '下载失败'))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (templateId: string) => {
|
||||
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return
|
||||
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
|
||||
setActionLoading(templateId)
|
||||
setError('')
|
||||
try {
|
||||
@@ -80,6 +81,8 @@ export default function ImageManagement() {
|
||||
}
|
||||
|
||||
const downloadedCount = images.filter((img) => img.downloaded).length
|
||||
const lxcImages = images.filter((img) => img.type === 'lxc')
|
||||
const kvmImages = images.filter((img) => img.type === 'kvm')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -95,7 +98,7 @@ export default function ImageManagement() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">镜像管理</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
管理 LXC 系统镜像模板,下载后的镜像才能用于创建容器。
|
||||
管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。
|
||||
已下载 {downloadedCount}/{images.length}
|
||||
</p>
|
||||
</div>
|
||||
@@ -115,6 +118,58 @@ export default function ImageManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImageTable
|
||||
title="LXC 容器镜像"
|
||||
images={lxcImages}
|
||||
actionLoading={actionLoading}
|
||||
downloadedCount={lxcImages.filter((img) => img.downloaded).length}
|
||||
totalCount={lxcImages.length}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
|
||||
<ImageTable
|
||||
title="KVM 虚拟机镜像"
|
||||
images={kvmImages}
|
||||
actionLoading={actionLoading}
|
||||
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
|
||||
totalCount={kvmImages.length}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageTable({
|
||||
title,
|
||||
images,
|
||||
actionLoading,
|
||||
downloadedCount,
|
||||
totalCount,
|
||||
onDownload,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string
|
||||
images: ImageInfo[]
|
||||
actionLoading: string | null
|
||||
downloadedCount: number
|
||||
totalCount: number
|
||||
onDownload: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
已下载 {downloadedCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -153,6 +208,7 @@ export default function ImageManagement() {
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
|
||||
<p className="text-[11px] text-gray-400">{img.description}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -172,7 +228,7 @@ export default function ImageManagement() {
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!img.downloaded && !img.downloading && (
|
||||
<button
|
||||
onClick={() => handleDownload(img.id)}
|
||||
onClick={() => onDownload(img.id)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -195,7 +251,7 @@ export default function ImageManagement() {
|
||||
{img.downloaded && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleToggle(img.id, img.enabled)}
|
||||
onClick={() => onToggle(img.id, img.enabled)}
|
||||
disabled={isBusy}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
img.enabled
|
||||
@@ -207,7 +263,7 @@ export default function ImageManagement() {
|
||||
{img.enabled ? '启用' : '禁用'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(img.id)}
|
||||
onClick={() => onDelete(img.id)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
|
||||
title="删除镜像缓存"
|
||||
@@ -262,8 +318,13 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function isWindowsImage(img: ImageInfo) {
|
||||
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
function getTemplateIcon(id: string): ReactNode {
|
||||
const size = 'w-5 h-5'
|
||||
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||
@@ -271,9 +332,15 @@ function getTemplateIcon(id: string): ReactNode {
|
||||
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
||||
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||
return null
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string) {
|
||||
const error = err as { response?: { data?: { message?: string } }; message?: string }
|
||||
return error.response?.data?.message || error.message || fallback
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes <= 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { Cpu, MemoryStick, HardDrive, RefreshCw, Save, RotateCcw } from 'lucide-react'
|
||||
import {
|
||||
getOversell,
|
||||
updateOversell,
|
||||
getOversellStatus,
|
||||
getHostInfo,
|
||||
reclaimMemory,
|
||||
HostInfo,
|
||||
OversellConfig,
|
||||
OversellStatus,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { formatMB } from '../utils/labels'
|
||||
|
||||
export default function Oversell() {
|
||||
const dialog = useDialog()
|
||||
const [config, setConfig] = useState<OversellConfig | null>(null)
|
||||
const [status, setStatus] = useState<OversellStatus | null>(null)
|
||||
const [host, setHost] = useState<HostInfo | null>(null)
|
||||
const [estimateSpec, setEstimateSpec] = useState({ vcpu: 1, ramMb: 1024, diskGb: 10 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reclaiming, setReclaiming] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [cfgRes, stRes, hostRes] = await Promise.all([
|
||||
getOversell(),
|
||||
getOversellStatus(),
|
||||
getHostInfo(),
|
||||
])
|
||||
if (cfgRes.data.data) setConfig(cfgRes.data.data)
|
||||
if (stRes.data.data) setStatus(stRes.data.data)
|
||||
if (hostRes.data.data) setHost(hostRes.data.data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return
|
||||
if (config.cpu_overcommit < 1 || config.ram_overcommit < 1 || config.disk_overcommit < 1) {
|
||||
await dialog.alert('参数错误', '超售倍数不能小于 1。')
|
||||
return
|
||||
}
|
||||
if (config.swappiness < 0 || config.swappiness > 100) {
|
||||
await dialog.alert('参数错误', 'Swap 倾向必须在 0 到 100 之间。')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateOversell(config)
|
||||
await fetchData()
|
||||
await dialog.alert('已应用', '宿主机控制参数已保存。')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
await dialog.alert('保存失败', getErrorMessage(err, '请检查宿主机权限或稍后重试。'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReclaimMemory = async () => {
|
||||
setReclaiming(true)
|
||||
try {
|
||||
const res = await reclaimMemory()
|
||||
await fetchData()
|
||||
const result = res.data.data
|
||||
const errors = result?.errors?.length ? `\n失败: ${result.errors.join('; ')}` : ''
|
||||
await dialog.alert(
|
||||
'回收已触发',
|
||||
`已处理 ${result?.attempted || 0} 个运行中容器,成功 ${result?.reclaimed || 0} 个,不支持 ${result?.unsupported || 0} 个。${errors}`
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
await dialog.alert('回收失败', getErrorMessage(err, '请检查宿主机是否支持 cgroup v2 memory.reclaim。'))
|
||||
} finally {
|
||||
setReclaiming(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config) return null
|
||||
|
||||
const estimate = host ? buildCapacityEstimate(host, status, config, estimateSpec) : null
|
||||
const ksmSupported = status?.ksm_supported !== false
|
||||
const reclaimSupported = status?.reclaim_supported !== false
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-black">宿主机控制</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">超售容量、KSM 与宿主机内存参数</p>
|
||||
</div>
|
||||
<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 className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<ResourceCard
|
||||
icon={<Cpu className="w-3.5 h-3.5" />}
|
||||
label="已分配 vCPU"
|
||||
value={String(status?.allocated_cpu || 0)}
|
||||
hint={`超售倍数: ${config.cpu_overcommit}x`}
|
||||
/>
|
||||
<ResourceCard
|
||||
icon={<MemoryStick className="w-3.5 h-3.5" />}
|
||||
label="已分配内存"
|
||||
value={formatMB(status?.allocated_ram_mb || 0)}
|
||||
hint={`超售倍数: ${config.ram_overcommit}x`}
|
||||
/>
|
||||
<ResourceCard
|
||||
icon={<HardDrive className="w-3.5 h-3.5" />}
|
||||
label="已分配磁盘"
|
||||
value={`${status?.allocated_disk_gb || 0} GB`}
|
||||
hint={`超售倍数: ${config.disk_overcommit}x`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black mb-4">超售倍数</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<SliderField
|
||||
label="CPU 超售"
|
||||
value={config.cpu_overcommit}
|
||||
min={1}
|
||||
max={32}
|
||||
suffix="x"
|
||||
onChange={(v) => setConfig({ ...config, cpu_overcommit: v })}
|
||||
hint="只用于容量估算,不改变单台容器限制"
|
||||
/>
|
||||
<SliderField
|
||||
label="内存超售"
|
||||
value={config.ram_overcommit}
|
||||
min={1}
|
||||
max={16}
|
||||
suffix="x"
|
||||
onChange={(v) => setConfig({ ...config, ram_overcommit: v })}
|
||||
hint="只用于容量估算,不改变单台容器限制"
|
||||
/>
|
||||
<SliderField
|
||||
label="磁盘超售"
|
||||
value={config.disk_overcommit}
|
||||
min={1}
|
||||
max={16}
|
||||
suffix="x"
|
||||
onChange={(v) => setConfig({ ...config, disk_overcommit: v })}
|
||||
hint="用于容量预估,实际写入仍受文件系统限制"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-sm font-semibold text-black">容量预估</h2>
|
||||
<span className="text-xs text-gray-500">按单台容器配置计算</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-5">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<NumberField
|
||||
label="vCPU"
|
||||
value={estimateSpec.vcpu}
|
||||
min={0.25}
|
||||
step={0.25}
|
||||
onChange={(value) => setEstimateSpec({ ...estimateSpec, vcpu: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="内存 MB"
|
||||
value={estimateSpec.ramMb}
|
||||
min={128}
|
||||
step={128}
|
||||
onChange={(value) => setEstimateSpec({ ...estimateSpec, ramMb: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="磁盘 GB"
|
||||
value={estimateSpec.diskGb}
|
||||
min={1}
|
||||
onChange={(value) => setEstimateSpec({ ...estimateSpec, diskGb: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{estimate && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[220px_1fr] gap-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="text-xs text-gray-500">预计最多可开</div>
|
||||
<div className="mt-1 text-3xl font-bold text-black">{estimate.remainingCount}</div>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
理论上限 {estimate.totalCount} 台,当前受 {estimate.bottleneckLabel} 限制
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">资源</th>
|
||||
<th className="px-3 py-2 text-right font-medium">实际</th>
|
||||
<th className="px-3 py-2 text-right font-medium">超售后</th>
|
||||
<th className="px-3 py-2 text-right font-medium">已分配</th>
|
||||
<th className="px-3 py-2 text-right font-medium">剩余可开</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{estimate.rows.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<td className="px-3 py-2 text-gray-700">{row.label}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.actual}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.capacity}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.allocated}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-black">{row.remainingCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<h2 className="text-sm font-semibold text-black mb-4">内存优化</h2>
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="KSM 合并"
|
||||
desc="合并容器间相同内存页,减少实际内存占用"
|
||||
value={config.ksm_enabled && ksmSupported}
|
||||
disabled={!ksmSupported}
|
||||
onChange={(v) => setConfig({ ...config, ksm_enabled: v })}
|
||||
extra={ksmSupported ? `已合并 ${status?.ksm_pages || 0} 页` : '当前内核不支持 KSM'}
|
||||
/>
|
||||
<SliderField
|
||||
label="Swap 倾向"
|
||||
value={config.swappiness}
|
||||
min={0}
|
||||
max={100}
|
||||
suffix=""
|
||||
onChange={(v) => setConfig({ ...config, swappiness: v })}
|
||||
hint="写入 /proc/sys/vm/swappiness,值越低越少使用 swap"
|
||||
/>
|
||||
<ActionRow
|
||||
title="立即回收缓存"
|
||||
desc={reclaimSupported ? '对运行中容器触发一次 cgroup v2 memory.reclaim' : '当前环境未检测到 memory.reclaim'}
|
||||
disabled={!reclaimSupported || reclaiming}
|
||||
busy={reclaiming}
|
||||
onClick={handleReclaimMemory}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? '保存中...' : '应用设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResourceCard({ icon, label, value, hint }: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
hint: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-1">
|
||||
{icon}{label}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-black">{value}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{hint}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SliderField({ label, value, min, max, suffix, onChange, hint }: {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
suffix: string
|
||||
onChange: (v: number) => void
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<span className="text-sm text-gray-500 font-mono">{value}{suffix}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10) || min)}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-gray-300 mt-0.5">
|
||||
<span>{min}{suffix}</span><span>{max}{suffix}</span>
|
||||
</div>
|
||||
{hint && <div className="text-[10px] text-gray-400 mt-1">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberField({ label, value, min, step = 1, onChange }: {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
step?: number
|
||||
onChange: (value: number) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-gray-600">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const parsed = step % 1 === 0 ? parseInt(e.target.value, 10) : parseFloat(e.target.value)
|
||||
onChange(Math.max(min, Number.isFinite(parsed) ? parsed : min))
|
||||
}}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black focus:border-black focus:outline-none focus:ring-2 focus:ring-black"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({ label, desc, value, disabled = false, onChange, extra }: {
|
||||
label: string
|
||||
desc: string
|
||||
value: boolean
|
||||
disabled?: boolean
|
||||
onChange: (v: boolean) => void
|
||||
extra?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">{label}</div>
|
||||
<div className="text-xs text-gray-400">{desc}</div>
|
||||
{extra && <div className="text-xs text-gray-500 mt-0.5">{extra}</div>}
|
||||
</div>
|
||||
<label className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-300 peer-checked:bg-black rounded-full after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4"></div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionRow({ title, desc, disabled, busy, onClick }: {
|
||||
title: string
|
||||
desc: string
|
||||
disabled: boolean
|
||||
busy: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||
<div className="text-xs text-gray-400">{desc}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className={`w-4 h-4 ${busy ? 'animate-spin' : ''}`} />
|
||||
{busy ? '回收中...' : '执行'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type EstimateSpec = {
|
||||
vcpu: number
|
||||
ramMb: number
|
||||
diskGb: number
|
||||
}
|
||||
|
||||
type EstimateRow = {
|
||||
label: string
|
||||
actual: string
|
||||
capacity: string
|
||||
allocated: string
|
||||
totalCount: number
|
||||
remainingCount: number
|
||||
}
|
||||
|
||||
function buildCapacityEstimate(
|
||||
host: HostInfo,
|
||||
status: OversellStatus | null,
|
||||
config: OversellConfig,
|
||||
spec: EstimateSpec
|
||||
) {
|
||||
const cpuCapacity = host.cpu.cores * config.cpu_overcommit
|
||||
const ramCapacity = host.ram.total_mb * config.ram_overcommit
|
||||
const diskCapacity = host.disk.total_gb * config.disk_overcommit
|
||||
|
||||
const allocatedCPU = status?.allocated_cpu || 0
|
||||
const allocatedRAM = status?.allocated_ram_mb || 0
|
||||
const allocatedDisk = status?.allocated_disk_gb || 0
|
||||
|
||||
const rows: EstimateRow[] = [
|
||||
{
|
||||
label: 'CPU',
|
||||
actual: `${host.cpu.cores} 核`,
|
||||
capacity: `${cpuCapacity} vCPU`,
|
||||
allocated: `${allocatedCPU} vCPU`,
|
||||
totalCount: safeFloor(cpuCapacity / spec.vcpu),
|
||||
remainingCount: safeFloor((cpuCapacity - allocatedCPU) / spec.vcpu),
|
||||
},
|
||||
{
|
||||
label: '内存',
|
||||
actual: formatMB(Number(host.ram.total_mb)),
|
||||
capacity: formatMB(ramCapacity),
|
||||
allocated: formatMB(allocatedRAM),
|
||||
totalCount: safeFloor(ramCapacity / spec.ramMb),
|
||||
remainingCount: safeFloor((ramCapacity - allocatedRAM) / spec.ramMb),
|
||||
},
|
||||
{
|
||||
label: '磁盘',
|
||||
actual: `${host.disk.total_gb} GB`,
|
||||
capacity: `${diskCapacity} GB`,
|
||||
allocated: `${allocatedDisk} GB`,
|
||||
totalCount: safeFloor(diskCapacity / spec.diskGb),
|
||||
remainingCount: safeFloor((diskCapacity - allocatedDisk) / spec.diskGb),
|
||||
},
|
||||
]
|
||||
|
||||
const totalCount = Math.min(...rows.map((row) => row.totalCount))
|
||||
const remainingCount = Math.min(...rows.map((row) => row.remainingCount))
|
||||
const bottleneck = rows.reduce((current, row) => row.remainingCount < current.remainingCount ? row : current, rows[0])
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalCount,
|
||||
remainingCount,
|
||||
bottleneckLabel: bottleneck.label,
|
||||
}
|
||||
}
|
||||
|
||||
function safeFloor(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown, fallback: string): string {
|
||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const response = (err as { response?: { data?: { message?: string } } }).response
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { RefreshCw, Search, Server, X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
|
||||
|
||||
export default function Routing() {
|
||||
const navigate = useNavigate()
|
||||
const [routing, setRouting] = useState<RoutingInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [nat4Page, setNat4Page] = useState(1)
|
||||
const [ipv6Page, setIPv6Page] = useState(1)
|
||||
const [nat4Search, setNat4Search] = useState('')
|
||||
const [ipv6Search, setIPv6Search] = useState('')
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await getRoutingInfo()
|
||||
setRouting(res.data.data || null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const nat4Mappings = routing?.nat4_mappings || []
|
||||
const ipv6Assignments = routing?.ipv6_assignments || []
|
||||
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
|
||||
|
||||
// Filter helpers
|
||||
const matchesNat4Search = (m: NAT4Route, query: string) => {
|
||||
if (!query) return true
|
||||
const q = query.toLowerCase()
|
||||
return (
|
||||
String(m.host_port).includes(q) ||
|
||||
String(m.container_port).includes(q) ||
|
||||
m.container_name.toLowerCase().includes(q) ||
|
||||
m.lxc_name.toLowerCase().includes(q) ||
|
||||
(m.ip || '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
const matchesIPv6Search = (item: IPv6Route, query: string) => {
|
||||
if (!query) return true
|
||||
const q = query.toLowerCase()
|
||||
return (
|
||||
(item.address || '').toLowerCase().includes(q) ||
|
||||
item.container_name.toLowerCase().includes(q) ||
|
||||
item.lxc_name.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search])
|
||||
const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search])
|
||||
|
||||
// Reset page on search change
|
||||
useEffect(() => { setNat4Page(1) }, [nat4Search])
|
||||
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const pageSize = 10
|
||||
const nat4TotalPages = Math.max(1, Math.ceil(filteredNat4.length / pageSize))
|
||||
const ipv6TotalPages = Math.max(1, Math.ceil(filteredIPv6.length / pageSize))
|
||||
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
|
||||
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
|
||||
const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
|
||||
const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-black">路由管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setRefreshing(true); fetchData() }}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<CapacityCard
|
||||
title="NAT4 端口"
|
||||
icon={<Nat4Icon />}
|
||||
remaining={routing?.nat4.remaining || '0'}
|
||||
total={routing?.nat4.total || '0'}
|
||||
used={routing?.nat4.used || 0}
|
||||
label="剩余端口 / 端口总数"
|
||||
/>
|
||||
<CapacityCard
|
||||
title="IPv6 地址"
|
||||
icon={<IPv6Icon />}
|
||||
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
|
||||
total={formatCapacity(routing?.ipv6.total || '0')}
|
||||
used={routing?.ipv6.used || 0}
|
||||
label={`剩余地址 / 地址总数 · ${ipv6Prefix}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-black dark:text-white">NAT4 端口分配</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''}共 {nat4Mappings.length} 条映射
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={nat4Search}
|
||||
onChange={e => setNat4Search(e.target.value)}
|
||||
placeholder="搜索端口/容器..."
|
||||
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
|
||||
/>
|
||||
{nat4Search && (
|
||||
<button onClick={() => setNat4Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{nat4Mappings.length === 0 ? (
|
||||
<EmptyState icon={<Nat4Icon className="h-7 w-7" />} text="暂无 NAT4 端口映射" />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium">容器 IPv4</th>
|
||||
<th className="px-4 py-3 text-left font-medium">宿主机端口</th>
|
||||
<th className="px-4 py-3 text-left font-medium">容器端口</th>
|
||||
<th className="px-4 py-3 text-left font-medium">协议</th>
|
||||
<th className="px-4 py-3 text-left font-medium">说明</th>
|
||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pagedNat4Mappings.map((mapping, index) => (
|
||||
<tr key={`${mapping.container_id}-${mapping.host_port}-${mapping.protocol}-${index}`} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/container/${mapping.container_id}`)}
|
||||
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||
>
|
||||
<Server className="h-4 w-4 text-gray-400" />
|
||||
{mapping.container_name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.lxc_name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{mapping.ip || '-'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.host_port}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700">{mapping.container_port}</td>
|
||||
<td className="px-4 py-3 uppercase text-gray-600">{mapping.protocol || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{mapping.description || '-'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={mapping.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={currentNat4Page}
|
||||
totalPages={nat4TotalPages}
|
||||
totalItems={filteredNat4.length}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setNat4Page}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-black dark:text-white">IPv6 地址分配</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''}共 {ipv6Assignments.length} 个地址
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={ipv6Search}
|
||||
onChange={e => setIPv6Search(e.target.value)}
|
||||
placeholder="搜索地址/容器..."
|
||||
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
|
||||
/>
|
||||
{ipv6Search && (
|
||||
<button onClick={() => setIPv6Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ipv6Assignments.length === 0 ? (
|
||||
<EmptyState icon={<IPv6Icon className="h-7 w-7" />} text="暂无 IPv6 地址分配" />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[820px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium">IPv6 地址</th>
|
||||
<th className="px-4 py-3 text-left font-medium">前缀</th>
|
||||
<th className="px-4 py-3 text-left font-medium">出口网卡</th>
|
||||
<th className="px-4 py-3 text-left font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pagedIPv6Assignments.map((item) => (
|
||||
<tr key={`${item.container_id}-${item.address}`} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/container/${item.container_id}`)}
|
||||
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||
>
|
||||
<Server className="h-4 w-4 text-gray-400" />
|
||||
{item.container_name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.lxc_name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">/{item.prefix_len || '-'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={currentIPv6Page}
|
||||
totalPages={ipv6TotalPages}
|
||||
totalItems={filteredIPv6.length}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setIPv6Page}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pagination({ page, totalPages, totalItems, pageSize, onPageChange }: {
|
||||
page: number
|
||||
totalPages: number
|
||||
totalItems: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
}) {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, totalItems)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 text-sm">
|
||||
<div className="text-xs text-gray-500">
|
||||
显示 {start}-{end},共 {totalItems} 条
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="min-w-16 text-center text-xs text-gray-500">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CapacityCard({ title, icon, remaining, total, used, label }: {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
remaining: string
|
||||
total: string
|
||||
used: number
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<span className="text-2xl font-semibold text-black">{remaining}</span>
|
||||
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-gray-100">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-500">{label}</div>
|
||||
<div className="mt-1 text-xs text-gray-400">已分配 {used}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700">{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const running = status === 'running'
|
||||
return (
|
||||
<span className={`rounded px-2 py-1 text-xs ${running ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{running ? '运行中' : (status || '未知')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCapacity(value: string): string {
|
||||
if (value === 'large') return '充足'
|
||||
return value
|
||||
}
|
||||
|
||||
function Nat4Icon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
|
||||
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM733.866667 401.066667v25.6h-34.133334v-25.6h-72.533333v-29.866667l64-123.733333h38.4l-64 123.733333h38.4v-34.133333h34.133333v34.133333h17.066667v29.866667h-21.333333z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IPv6Icon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
|
||||
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM691.2 426.666667c-34.133333 0-55.466667-21.333333-55.466667-55.466667 0-17.066667 8.533333-34.133333 17.066667-46.933333l38.4-76.8h38.4l-38.4 76.8c4.266667 0 8.533333-4.266667 12.8-4.266667 25.6 0 46.933333 21.333333 46.933333 55.466667-4.266667 29.866667-29.866667 51.2-59.733333 51.2z m0-81.066667c-12.8 0-25.6 8.533333-25.6 25.6 0 17.066667 8.533333 25.6 25.6 25.6s25.6-8.533333 25.6-25.6c-4.266667-17.066667-12.8-25.6-25.6-25.6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+156
-13
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
|
||||
export default function Snapshots() {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await getSnapshots()
|
||||
setSnapshots(res.data.data || [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleDelete = async (snapshot: Snapshot) => {
|
||||
const confirmed = await dialog.confirm(
|
||||
'删除快照',
|
||||
`确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。`
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setDeleting(snapshot.id)
|
||||
try {
|
||||
await deleteContainerSnapshot(snapshot.container_id, snapshot.id)
|
||||
setSnapshots(prev => prev.filter(s => s.id !== snapshot.id))
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('删除失败', error.response?.data?.message || '请稍后重试')
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-black">快照管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">全局快照列表,共 {snapshots.length} 个</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setRefreshing(true); fetchData() }}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Camera className="h-7 w-7 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700">暂无快照</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full min-w-[820px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium">快照时间</th>
|
||||
<th className="px-4 py-3 text-left font-medium">类型</th>
|
||||
<th className="px-4 py-3 text-left font-medium">创建者</th>
|
||||
<th className="px-4 py-3 text-right font-medium">大小</th>
|
||||
<th className="px-4 py-3 text-center font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/container/${snapshot.container_id}`)}
|
||||
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||
>
|
||||
<Server className="h-4 w-4 text-gray-400" />
|
||||
{snapshot.container_name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{snapshot.lxc_name}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{snapshot.created_at}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{snapshot.scheduled ? '定时' : '手动'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDelete(snapshot)}
|
||||
disabled={deleting === snapshot.id}
|
||||
className="inline-flex items-center justify-center p-1.5 rounded text-red-500 hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||
title="删除快照"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import api, { AuditLog, LoginLog } from '../services/api'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
interface SubUserItem {
|
||||
id: string
|
||||
username: string
|
||||
container_names: string[]
|
||||
container_uuids: string[]
|
||||
container_name: string
|
||||
container_uuid: string
|
||||
access_code: string
|
||||
password?: string
|
||||
created_at: string
|
||||
last_login: string
|
||||
last_login_ip: string
|
||||
last_login_ua: string
|
||||
}
|
||||
|
||||
interface AuditLogExt extends AuditLog {
|
||||
ip?: string
|
||||
user_agent?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function SubUserManagement() {
|
||||
const dialog = useDialog()
|
||||
const [users, setUsers] = useState<SubUserItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
|
||||
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
||||
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 {
|
||||
const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users')
|
||||
setUsers(res.data.data || [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchUsers() }, [fetchUsers])
|
||||
|
||||
const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}`
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
await copyToClipboard(text)
|
||||
}
|
||||
|
||||
const rotatePassword = async (user: SubUserItem) => {
|
||||
setRotatingPassword(true)
|
||||
try {
|
||||
const res = await api.post(`/sub-users/${user.id}/rotate-password`)
|
||||
const data = res.data.data
|
||||
const updatedUser = {
|
||||
...user,
|
||||
username: data?.username || user.username,
|
||||
access_code: data?.access_code || user.access_code,
|
||||
password: data?.password || '',
|
||||
}
|
||||
setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item)))
|
||||
setPasswordUser(updatedUser)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试')
|
||||
} finally {
|
||||
setRotatingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showAuditLogs = async (user: SubUserItem) => {
|
||||
try {
|
||||
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
||||
setAuditLogs(res.data.data || [])
|
||||
setLoginLogs(null)
|
||||
setModalTitle(`${user.username} - 操作日志`)
|
||||
setLogPage(1)
|
||||
} catch {
|
||||
dialog.alert('错误', '获取操作日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const showLoginLogs = async (user: SubUserItem) => {
|
||||
try {
|
||||
const res = await api.get(`/sub-users/${user.id}/login-logs`)
|
||||
setLoginLogs(res.data.data || [])
|
||||
setAuditLogs(null)
|
||||
setModalTitle(`${user.username} - 登录日志`)
|
||||
setLogPage(1)
|
||||
} catch {
|
||||
dialog.alert('错误', '获取登录日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setAuditLogs(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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-black dark:text-white">子用户管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">容器分配的子用户列表,共 {users.length} 个</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
{users.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800">
|
||||
<UserCog className="h-7 w-7 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">暂无子用户</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full min-w-[820px] text-sm">
|
||||
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium w-12">#</th>
|
||||
<th className="px-4 py-3 text-left font-medium">容器名称</th>
|
||||
<th className="px-4 py-3 text-left font-medium">UUID</th>
|
||||
<th className="px-4 py-3 text-left font-medium">最后登录</th>
|
||||
<th className="px-4 py-3 text-center font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{users.map((user, index) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td className="px-4 py-3 text-gray-400 dark:text-gray-500">{index + 1}</td>
|
||||
<td className="px-4 py-3 font-medium text-black dark:text-white">{user.container_name || '-'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{user.container_uuid || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{user.last_login ? (
|
||||
<div>
|
||||
<div className="text-xs">{user.last_login}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{user.last_login_ip}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">从未登录</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => setPasswordUser(user)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 transition-colors"
|
||||
title="查看密码"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
查看密码
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showAuditLogs(user)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors"
|
||||
title="查看操作日志"
|
||||
>
|
||||
<ScrollText className="w-3.5 h-3.5" />
|
||||
操作日志
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showLoginLogs(user)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
|
||||
title="查看登录日志"
|
||||
>
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
登录日志
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{passwordUser && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">查看密码</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => rotatePassword(passwordUser)}
|
||||
disabled={rotatingPassword}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-amber-700 bg-amber-50 hover:bg-amber-100 dark:text-amber-300 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 disabled:opacity-50"
|
||||
title="轮换密码"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${rotatingPassword ? 'animate-spin' : ''}`} />
|
||||
{rotatingPassword ? '轮换中...' : '轮换密码'}
|
||||
</button>
|
||||
<button onClick={() => setPasswordUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500 dark:text-gray-400">用户</span>
|
||||
<span className="min-w-0 text-right font-medium text-black dark:text-white break-all">{passwordUser.username}</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500 dark:text-gray-400">地址</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl(passwordUser)}</span>
|
||||
<button onClick={() => copyText(managementUrl(passwordUser))} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-gray-500 dark:text-gray-400">密码</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="font-mono text-xs text-black dark:text-white break-all">
|
||||
{passwordUser.password || '未保存,请轮换生成新密码'}
|
||||
</span>
|
||||
{passwordUser.password && (
|
||||
<button onClick={() => copyText(passwordUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Modal */}
|
||||
{(auditLogs || loginLogs) && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">{modalTitle}</h3>
|
||||
<button onClick={closeModal} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1">
|
||||
{auditLogs && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">操作时间</th>
|
||||
<th className="px-4 py-2 text-left">操作</th>
|
||||
<th className="px-4 py-2 text-left">IP</th>
|
||||
<th className="px-4 py-2 text-left">UA</th>
|
||||
<th className="px-4 py-2 text-center">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
) : 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>
|
||||
<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 text-gray-500 dark:text-gray-400 max-w-[200px] truncate" title={log.user_agent}>{log.user_agent || '-'}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{log.success !== undefined ? (
|
||||
log.success ? (
|
||||
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400">成功</span>
|
||||
) : (
|
||||
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400" title={log.error}>{log.error ? '失败' : '失败'}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{loginLogs && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">登录时间</th>
|
||||
<th className="px-4 py-2 text-left">登录 IP</th>
|
||||
<th className="px-4 py-2 text-left">UA</th>
|
||||
<th className="px-4 py-2 text-center">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
) : 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>
|
||||
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[250px] truncate" title={log.user_agent}>{log.user_agent}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{log.success ? (
|
||||
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400">成功</span>
|
||||
) : (
|
||||
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400">失败</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+152
-35
@@ -48,6 +48,7 @@ export interface Container {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
virtualization?: string
|
||||
template: string
|
||||
vcpu: number
|
||||
ram_mb: number
|
||||
@@ -71,13 +72,24 @@ export interface Container {
|
||||
ssh_password: string
|
||||
port_mappings: PortMapping[]
|
||||
port_mapping_limit: number
|
||||
snapshot_limit: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
snapshot_schedule_enabled: boolean
|
||||
snapshot_schedule_interval_hours: number
|
||||
snapshot_schedule_time: string
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
type?: string
|
||||
distro: string
|
||||
release: string
|
||||
arch: string
|
||||
@@ -87,6 +99,7 @@ export interface Template {
|
||||
|
||||
export interface CreateContainerRequest {
|
||||
name: string
|
||||
virtualization: string
|
||||
template_id: string
|
||||
vcpu: number
|
||||
cpu_percent: number
|
||||
@@ -100,6 +113,7 @@ export interface CreateContainerRequest {
|
||||
io_speed_mbps: number
|
||||
extra_ports: number[]
|
||||
port_mapping_count: number
|
||||
snapshot_limit: number
|
||||
assign_ipv6: boolean
|
||||
expires_at: string
|
||||
}
|
||||
@@ -148,6 +162,7 @@ export interface HostInfo {
|
||||
|
||||
export interface ContainerUsage {
|
||||
memory_usage_bytes: number
|
||||
memory_total_bytes?: number
|
||||
cpu_usage_usec: number
|
||||
cpu_usage_pct: number
|
||||
disk_usage_bytes: number
|
||||
@@ -159,6 +174,10 @@ export interface ContainerUsage {
|
||||
disk_write_bytes: number
|
||||
disk_read_bps: number
|
||||
disk_write_bps: number
|
||||
load1: number
|
||||
load5: number
|
||||
load15: number
|
||||
guest_metrics?: boolean
|
||||
}
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
@@ -189,6 +208,14 @@ export interface LoginLog {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
time: string
|
||||
action: string
|
||||
target: string
|
||||
detail: string
|
||||
user: string
|
||||
}
|
||||
|
||||
export const getLoginLogs = () =>
|
||||
api.get<APIResponse<LoginLog[]>>('/login-logs')
|
||||
|
||||
@@ -275,6 +302,45 @@ export const getIPv6Status = () =>
|
||||
export const assignIPv6 = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
|
||||
|
||||
export interface RouteCapacity {
|
||||
used: number
|
||||
remaining: string
|
||||
total: string
|
||||
}
|
||||
|
||||
export interface NAT4Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
lxc_name: string
|
||||
status: string
|
||||
ip: string
|
||||
host_port: number
|
||||
container_port: number
|
||||
protocol: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface IPv6Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
lxc_name: string
|
||||
status: string
|
||||
address: string
|
||||
prefix_len: number
|
||||
interface: string
|
||||
}
|
||||
|
||||
export interface RoutingInfo {
|
||||
nat4: RouteCapacity
|
||||
ipv6: RouteCapacity
|
||||
nat4_mappings: NAT4Route[]
|
||||
ipv6_assignments: IPv6Route[]
|
||||
ipv6_prefixes: IPv6PrefixInfo[]
|
||||
}
|
||||
|
||||
export const getRoutingInfo = () =>
|
||||
api.get<APIResponse<RoutingInfo>>('/routing')
|
||||
|
||||
// Templates
|
||||
export const getTemplates = () =>
|
||||
api.get<APIResponse<Template[]>>('/templates')
|
||||
@@ -283,6 +349,7 @@ export const getTemplates = () =>
|
||||
export interface ImageInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
distro: string
|
||||
release: string
|
||||
arch: string
|
||||
@@ -291,13 +358,14 @@ export interface ImageInfo {
|
||||
enabled: boolean
|
||||
downloading: boolean
|
||||
size_bytes: number
|
||||
manual_path?: string
|
||||
}
|
||||
|
||||
export const getImages = () =>
|
||||
api.get<APIResponse<ImageInfo[]>>('/images')
|
||||
|
||||
export const downloadImage = (templateId: string) =>
|
||||
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout
|
||||
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout
|
||||
|
||||
export const deleteImage = (templateId: string) =>
|
||||
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
|
||||
@@ -305,8 +373,8 @@ export const deleteImage = (templateId: string) =>
|
||||
export const toggleImage = (templateId: string, enabled: boolean) =>
|
||||
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
||||
|
||||
export const getEnabledImages = () =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled')
|
||||
export const getEnabledImages = (virtualization = 'lxc') =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
|
||||
|
||||
// Dashboard
|
||||
export const getDashboard = () =>
|
||||
@@ -315,44 +383,61 @@ export const getDashboard = () =>
|
||||
export const getHostInfo = () =>
|
||||
api.get<APIResponse<HostInfo>>('/host-info')
|
||||
|
||||
// Oversell
|
||||
export interface OversellConfig {
|
||||
cpu_overcommit: number
|
||||
ram_overcommit: number
|
||||
disk_overcommit: number
|
||||
ksm_enabled: boolean
|
||||
swappiness: number
|
||||
// Snapshots
|
||||
export interface Snapshot {
|
||||
id: string
|
||||
container_id: number
|
||||
container_name: string
|
||||
lxc_name: string
|
||||
created_at: string
|
||||
created_by: string
|
||||
scheduled: boolean
|
||||
path: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export interface OversellStatus {
|
||||
ksm_active: boolean
|
||||
ksm_pages: number
|
||||
ksm_supported: boolean
|
||||
swappiness: number
|
||||
reclaim_supported: boolean
|
||||
allocated_cpu: number
|
||||
allocated_ram_mb: number
|
||||
allocated_disk_gb: number
|
||||
export interface SnapshotSchedule {
|
||||
enabled: boolean
|
||||
interval_hours: number
|
||||
time: string
|
||||
last_run: string
|
||||
next_run: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface ReclaimResult {
|
||||
attempted: number
|
||||
reclaimed: number
|
||||
unsupported: number
|
||||
errors: string[]
|
||||
export interface ContainerSnapshotsResponse {
|
||||
snapshots: Snapshot[]
|
||||
quota: number
|
||||
schedule: SnapshotSchedule
|
||||
}
|
||||
|
||||
export const getOversell = () =>
|
||||
api.get<APIResponse<OversellConfig>>('/oversell')
|
||||
export const getSnapshots = () =>
|
||||
api.get<APIResponse<Snapshot[]>>('/snapshots')
|
||||
|
||||
export const updateOversell = (data: OversellConfig) =>
|
||||
api.post<APIResponse<OversellConfig>>('/oversell', data)
|
||||
export const getContainerSnapshots = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
|
||||
|
||||
export const getOversellStatus = () =>
|
||||
api.get<APIResponse<OversellStatus>>('/oversell/status')
|
||||
export const createContainerSnapshot = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
|
||||
|
||||
export const reclaimMemory = () =>
|
||||
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
|
||||
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
|
||||
|
||||
export const restoreContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||
api.post<APIResponse>(`/containers/${id}/snapshots/${snapshotId}/restore`, {}, { timeout: 600000 })
|
||||
|
||||
export const updateSnapshotSchedule = (id: ContainerIdentifier, enabled: boolean, intervalHours: number, time: string) =>
|
||||
api.post<APIResponse<{ container: Container; snapshot?: Snapshot }>>(
|
||||
`/containers/${id}/snapshots/schedule`,
|
||||
{ enabled, interval_hours: intervalHours, time },
|
||||
{ timeout: 600000 }
|
||||
)
|
||||
|
||||
export const updateSnapshotQuota = (id: ContainerIdentifier, snapshotLimit: number) =>
|
||||
api.put<APIResponse<{ container: Container; quota: number }>>(
|
||||
`/containers/${id}/snapshots/quota`,
|
||||
{ snapshot_limit: snapshotLimit }
|
||||
)
|
||||
|
||||
// WebSSH URL generator
|
||||
export const getWebSSHUrl = (containerName: string) => {
|
||||
@@ -361,6 +446,13 @@ export const getWebSSHUrl = (containerName: string) => {
|
||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||
}
|
||||
|
||||
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const params = new URLSearchParams({ container: containerName })
|
||||
if (ticket) params.set('ticket', ticket)
|
||||
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
||||
}
|
||||
|
||||
// Task Queue
|
||||
export interface Task {
|
||||
id: string
|
||||
@@ -390,10 +482,9 @@ export const batchAction = (action: string, containers: number[], templateId?: s
|
||||
export interface SubUser {
|
||||
id: string
|
||||
username: string
|
||||
password: string
|
||||
password?: string
|
||||
container_names: string[]
|
||||
container_uuids?: string[]
|
||||
token: string
|
||||
access_code: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -436,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')
|
||||
|
||||
@@ -443,12 +547,25 @@ 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 })
|
||||
|
||||
export const createVNCTicket = (containerName: string) =>
|
||||
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
|
||||
|
||||
// Version
|
||||
export const getVersion = () =>
|
||||
api.get<APIResponse<{ version: string }>>('/version')
|
||||
|
||||
export default api
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (!text) return false
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Fall through for non-secure HTTP origins where Clipboard API is blocked.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '0'
|
||||
textarea.style.left = '0'
|
||||
textarea.style.width = '1px'
|
||||
textarea.style.height = '1px'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.style.pointerEvents = 'none'
|
||||
|
||||
const selection = document.getSelection()
|
||||
const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus({ preventScroll: true })
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
|
||||
let copied = false
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
if (selection && selectedRange) {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(selectedRange)
|
||||
}
|
||||
}
|
||||
|
||||
return copied
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
|
||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
target: 'es2022',
|
||||
}
|
||||
})
|
||||
|
||||
+322
-61
@@ -50,7 +50,7 @@ usage() {
|
||||
cat << EOF
|
||||
Usage:
|
||||
./install.sh
|
||||
./install.sh uninstall [--purge-data] [--delete-containers]
|
||||
./install.sh uninstall
|
||||
|
||||
Environment:
|
||||
CLICD_REPO=owner/repo
|
||||
@@ -71,30 +71,225 @@ remove_path() {
|
||||
log "Removed $path"
|
||||
}
|
||||
|
||||
uninstall_clicd() {
|
||||
purge_data=0
|
||||
delete_containers=0
|
||||
unmount_path_tree() {
|
||||
path="$1"
|
||||
if [ ! -e "$path" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
shift || true
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--purge-data)
|
||||
purge_data=1
|
||||
;;
|
||||
--delete-containers)
|
||||
delete_containers=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "Unknown uninstall option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
if has_cmd findmnt; then
|
||||
findmnt -R -n -o TARGET "$path" 2>/dev/null | sort -r | while IFS= read -r mountpoint; do
|
||||
[ -n "$mountpoint" ] || continue
|
||||
umount -R -l "$mountpoint" >/dev/null 2>&1 || umount -l "$mountpoint" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
umount -R -l "$path/rootfs" >/dev/null 2>&1 || umount -l "$path/rootfs" >/dev/null 2>&1 || true
|
||||
umount -R -l "$path" >/dev/null 2>&1 || umount -l "$path" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
detach_container_loop_devices() {
|
||||
path="$1"
|
||||
if ! has_cmd losetup; then
|
||||
return
|
||||
fi
|
||||
|
||||
for image in "$path"/rootfs.img "$path"/*.img; do
|
||||
[ -e "$image" ] || continue
|
||||
losetup -j "$image" 2>/dev/null | sed 's/:.*//' | while IFS= read -r loopdev; do
|
||||
[ -n "$loopdev" ] || continue
|
||||
losetup -d "$loopdev" >/dev/null 2>&1 || true
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
kill_path_users() {
|
||||
path="$1"
|
||||
if has_cmd fuser && [ -e "$path" ]; then
|
||||
fuser -km "$path" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
remove_lxc_container_dir() {
|
||||
container_dir="$1"
|
||||
container_name="$(basename "$container_dir")"
|
||||
|
||||
if has_cmd lxc-stop; then
|
||||
lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true
|
||||
fi
|
||||
if has_cmd lxc-destroy; then
|
||||
lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
unmount_path_tree "$container_dir"
|
||||
detach_container_loop_devices "$container_dir"
|
||||
|
||||
if rm -rf "$container_dir" >/dev/null 2>&1; then
|
||||
log "Removed $container_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
log "Retrying removal after terminating processes using $container_dir..."
|
||||
kill_path_users "$container_dir/rootfs"
|
||||
kill_path_users "$container_dir"
|
||||
unmount_path_tree "$container_dir"
|
||||
detach_container_loop_devices "$container_dir"
|
||||
rm -rf "$container_dir"
|
||||
log "Removed $container_dir"
|
||||
}
|
||||
|
||||
remove_kvm_domain() {
|
||||
domain="$1"
|
||||
case "$domain" in
|
||||
vm-[0-9]*)
|
||||
;;
|
||||
*)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
suffix="${domain#vm-}"
|
||||
case "$suffix" in
|
||||
""|*[!0-9]*)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
if [ ! -d "/var/lib/clicd/kvm/instances/$domain" ] &&
|
||||
! virsh dumpxml "$domain" 2>/dev/null | grep -q '/var/lib/clicd/kvm/'; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "Removing KVM domain $domain..."
|
||||
virsh destroy "$domain" >/dev/null 2>&1 || true
|
||||
virsh undefine "$domain" --remove-all-storage --nvram >/dev/null 2>&1 ||
|
||||
virsh undefine "$domain" --nvram >/dev/null 2>&1 ||
|
||||
virsh undefine "$domain" >/dev/null 2>&1 ||
|
||||
true
|
||||
}
|
||||
|
||||
destroy_clicd_kvm_domains() {
|
||||
if ! has_cmd virsh; then
|
||||
return
|
||||
fi
|
||||
|
||||
log "Destroying CLICD KVM domains..."
|
||||
virsh list --all --name 2>/dev/null | while IFS= read -r domain; do
|
||||
[ -n "$domain" ] || continue
|
||||
remove_kvm_domain "$domain"
|
||||
done
|
||||
}
|
||||
|
||||
delete_iptables_lines() {
|
||||
table="$1"
|
||||
chain="$2"
|
||||
pattern="$3"
|
||||
if ! has_cmd iptables; then
|
||||
return
|
||||
fi
|
||||
|
||||
while :; do
|
||||
line="$(iptables -t "$table" -L "$chain" -n --line-numbers 2>/dev/null | awk -v pat="$pattern" '$0 ~ pat {print $1; exit}')"
|
||||
[ -n "$line" ] || break
|
||||
iptables -t "$table" -D "$chain" "$line" >/dev/null 2>&1 || break
|
||||
done
|
||||
}
|
||||
|
||||
delete_iptables_rule() {
|
||||
table="$1"
|
||||
shift
|
||||
if ! has_cmd iptables; then
|
||||
return
|
||||
fi
|
||||
|
||||
while iptables -t "$table" -D "$@" >/dev/null 2>&1; do
|
||||
:
|
||||
done
|
||||
}
|
||||
|
||||
delete_filter_rule() {
|
||||
if ! has_cmd iptables; then
|
||||
return
|
||||
fi
|
||||
|
||||
while iptables -D "$@" >/dev/null 2>&1; do
|
||||
:
|
||||
done
|
||||
}
|
||||
|
||||
delete_ip6tables_bridge_rules() {
|
||||
if ! has_cmd ip6tables; then
|
||||
return
|
||||
fi
|
||||
|
||||
for bridge in lxcbr0 virbr0; do
|
||||
while :; do
|
||||
rule="$(ip6tables -S FORWARD 2>/dev/null | grep -- "$bridge" | sed 's/^-A /-D /' | head -n 1)"
|
||||
[ -n "$rule" ] || break
|
||||
# shellcheck disable=SC2086
|
||||
ip6tables $rule >/dev/null 2>&1 || break
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_clicd_networking() {
|
||||
log "Cleaning CLICD firewall and bridge rules..."
|
||||
delete_iptables_lines nat PREROUTING 'clicd-'
|
||||
delete_iptables_rule nat POSTROUTING -s 10.0.3.0/24 -o eth+ -j MASQUERADE
|
||||
delete_iptables_rule nat POSTROUTING -s 192.168.122.0/24 -o eth+ -j MASQUERADE
|
||||
|
||||
for bridge in lxcbr0 virbr0; do
|
||||
delete_filter_rule FORWARD -i "$bridge" -j ACCEPT
|
||||
delete_filter_rule FORWARD -o "$bridge" -j ACCEPT
|
||||
delete_filter_rule FORWARD -i "$bridge" -o "$bridge" -j ACCEPT
|
||||
done
|
||||
delete_ip6tables_bridge_rules
|
||||
}
|
||||
|
||||
remove_clicd_host_hooks() {
|
||||
if has_cmd systemctl; then
|
||||
systemctl stop clicd-kvm-ipv6.service >/dev/null 2>&1 || true
|
||||
systemctl disable clicd-kvm-ipv6.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
if has_cmd rc-service; then
|
||||
rc-service clicd-kvm-ipv6 stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
if has_cmd rc-update; then
|
||||
rc-update del clicd-kvm-ipv6 default >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
remove_path /usr/local/sbin/clicd-kvm-ipv6-init
|
||||
remove_path /etc/systemd/system/clicd-kvm-ipv6.service
|
||||
remove_path /etc/local.d/clicd-kvm-ipv6.start
|
||||
remove_path /etc/network/if-up.d/clicd-kvm-ipv6
|
||||
}
|
||||
|
||||
remove_clicd_quota_records() {
|
||||
for file in /etc/projects /etc/projid; do
|
||||
[ -f "$file" ] || continue
|
||||
tmp="${file}.clicd-clean.$$"
|
||||
grep -v 'clicd-' "$file" > "$tmp" || true
|
||||
cat "$tmp" > "$file"
|
||||
rm -f "$tmp"
|
||||
log "Cleaned CLICD quota records from $file"
|
||||
done
|
||||
}
|
||||
|
||||
remove_clicd_tmp_files() {
|
||||
for path in /tmp/clicd-* /tmp/clicd.*; do
|
||||
[ -e "$path" ] || [ -L "$path" ] || continue
|
||||
rm -rf "$path"
|
||||
log "Removed $path"
|
||||
done
|
||||
}
|
||||
|
||||
remove_clicd_swapfile() {
|
||||
if [ ! -e /swapfile ]; then
|
||||
return
|
||||
fi
|
||||
swapoff /swapfile >/dev/null 2>&1 || true
|
||||
remove_path /swapfile
|
||||
}
|
||||
|
||||
uninstall_clicd() {
|
||||
log "Uninstalling CLICD..."
|
||||
|
||||
if has_cmd systemctl; then
|
||||
@@ -109,16 +304,15 @@ uninstall_clicd() {
|
||||
rc-update del clicd default >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ "$delete_containers" -eq 1 ]; then
|
||||
log "Destroying CLICD-style LXC containers named ct-*..."
|
||||
for container_dir in /var/lib/lxc/ct-*; do
|
||||
[ -d "$container_dir" ] || continue
|
||||
container_name="$(basename "$container_dir")"
|
||||
lxc-stop -n "$container_name" -k >/dev/null 2>&1 || true
|
||||
lxc-destroy -n "$container_name" -f >/dev/null 2>&1 || true
|
||||
remove_path "$container_dir"
|
||||
done
|
||||
fi
|
||||
log "Destroying LXC containers under /var/lib/lxc..."
|
||||
for container_dir in /var/lib/lxc/*; do
|
||||
[ -d "$container_dir" ] || continue
|
||||
remove_lxc_container_dir "$container_dir"
|
||||
done
|
||||
destroy_clicd_kvm_domains
|
||||
cleanup_clicd_networking
|
||||
remove_clicd_host_hooks
|
||||
remove_clicd_quota_records
|
||||
|
||||
remove_path /etc/systemd/system/clicd.service
|
||||
remove_path /etc/init.d/clicd
|
||||
@@ -126,10 +320,16 @@ uninstall_clicd() {
|
||||
remove_path /etc/sysctl.d/99-clicd.conf
|
||||
remove_path /var/log/clicd.log
|
||||
remove_path /var/log/clicd.err
|
||||
|
||||
if [ "$purge_data" -eq 1 ]; then
|
||||
remove_path /root/.clicd
|
||||
fi
|
||||
remove_path /root/.clicd
|
||||
unmount_path_tree /var/lib/lxc
|
||||
remove_path /var/lib/lxc
|
||||
unmount_path_tree /var/lib/clicd
|
||||
remove_path /var/lib/clicd
|
||||
remove_path /var/cache/lxc
|
||||
remove_path /var/cache/clicd
|
||||
remove_path /root/clicd-backups
|
||||
remove_clicd_tmp_files
|
||||
remove_clicd_swapfile
|
||||
|
||||
if has_cmd systemctl; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
@@ -143,14 +343,9 @@ uninstall_clicd() {
|
||||
echo "====================================="
|
||||
echo " CLICD Uninstalled"
|
||||
echo "====================================="
|
||||
if [ "$purge_data" -eq 0 ]; then
|
||||
echo " Kept data: /root/.clicd"
|
||||
fi
|
||||
if [ "$delete_containers" -eq 0 ]; then
|
||||
echo " Kept LXC containers: /var/lib/lxc"
|
||||
echo " To delete CLICD-style ct-* containers too:"
|
||||
echo " ./install.sh uninstall --delete-containers"
|
||||
fi
|
||||
echo " Removed service, binary, SQLite/config data, LXC containers,"
|
||||
echo " CLICD KVM domains, VM images, image caches, firewall rules,"
|
||||
echo " host hooks, quota records, temp files, backups, and swapfile."
|
||||
echo "====================================="
|
||||
}
|
||||
|
||||
@@ -158,7 +353,7 @@ case "$ACTION" in
|
||||
install|"")
|
||||
;;
|
||||
uninstall|remove)
|
||||
uninstall_clicd "$@"
|
||||
uninstall_clicd
|
||||
exit 0
|
||||
;;
|
||||
-h|--help|help)
|
||||
@@ -188,9 +383,16 @@ install_apk() {
|
||||
bridge-utils \
|
||||
iproute2 \
|
||||
iptables \
|
||||
dnsmasq
|
||||
dnsmasq \
|
||||
dbus \
|
||||
qemu-system-x86_64 \
|
||||
qemu-img \
|
||||
libvirt \
|
||||
libvirt-daemon \
|
||||
libvirt-client \
|
||||
libvirt-qemu
|
||||
|
||||
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs; do
|
||||
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do
|
||||
apk add --no-cache "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
|
||||
done
|
||||
}
|
||||
@@ -217,7 +419,16 @@ install_apt() {
|
||||
quota \
|
||||
e2fsprogs \
|
||||
xfsprogs \
|
||||
dnsmasq-base
|
||||
dnsmasq-base \
|
||||
qemu-kvm \
|
||||
qemu-utils \
|
||||
libvirt-daemon-system \
|
||||
libvirt-clients \
|
||||
cloud-image-utils \
|
||||
genisoimage \
|
||||
xorriso \
|
||||
virtinst \
|
||||
ovmf
|
||||
}
|
||||
|
||||
enable_el_repos() {
|
||||
@@ -253,9 +464,19 @@ install_dnf() {
|
||||
quota \
|
||||
e2fsprogs \
|
||||
xfsprogs \
|
||||
dnsmasq
|
||||
dnsmasq \
|
||||
qemu-kvm \
|
||||
qemu-img \
|
||||
libvirt \
|
||||
libvirt-daemon-kvm \
|
||||
libvirt-client \
|
||||
virt-install \
|
||||
cloud-utils \
|
||||
genisoimage
|
||||
|
||||
dnf install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
|
||||
for pkg in lxcfs xorriso edk2-ovmf; do
|
||||
dnf install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
|
||||
done
|
||||
}
|
||||
|
||||
install_yum() {
|
||||
@@ -278,9 +499,19 @@ install_yum() {
|
||||
quota \
|
||||
e2fsprogs \
|
||||
xfsprogs \
|
||||
dnsmasq
|
||||
dnsmasq \
|
||||
qemu-kvm \
|
||||
qemu-img \
|
||||
libvirt \
|
||||
libvirt-daemon-kvm \
|
||||
libvirt-client \
|
||||
virt-install \
|
||||
cloud-utils \
|
||||
genisoimage
|
||||
|
||||
yum install -y lxcfs >/dev/null 2>&1 || log "Optional package not installed: lxcfs"
|
||||
for pkg in lxcfs xorriso edk2-ovmf; do
|
||||
yum install -y "$pkg" >/dev/null 2>&1 || log "Optional package not installed: $pkg"
|
||||
done
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
@@ -318,6 +549,15 @@ install_dependencies() {
|
||||
has_cmd lxc-create || die "lxc-create is still missing after dependency installation."
|
||||
has_cmd iptables || die "iptables is still missing after dependency installation."
|
||||
has_cmd ip || die "iproute2/ip command is still missing after dependency installation."
|
||||
has_cmd virsh || die "virsh is still missing after dependency installation."
|
||||
has_cmd qemu-img || die "qemu-img is still missing after dependency installation."
|
||||
has_cmd cloud-localds || die "cloud-localds is still missing after dependency installation."
|
||||
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
|
||||
die "one of genisoimage, mkisofs, or xorriso is required for Windows KVM setup."
|
||||
fi
|
||||
if [ ! -e /dev/kvm ]; then
|
||||
log "Warning: /dev/kvm was not found. KVM VMs require hardware virtualization or nested virtualization."
|
||||
fi
|
||||
}
|
||||
|
||||
configure_kernel_networking() {
|
||||
@@ -333,13 +573,17 @@ EOF
|
||||
sysctl --system >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
setup_lxc_services() {
|
||||
log "Configuring LXC services..."
|
||||
setup_runtime_services() {
|
||||
log "Configuring LXC and KVM services..."
|
||||
|
||||
if is_systemd; then
|
||||
systemctl enable --now lxcfs >/dev/null 2>&1 || true
|
||||
systemctl enable --now lxc-net >/dev/null 2>&1 || true
|
||||
systemctl enable --now lxc >/dev/null 2>&1 || true
|
||||
systemctl enable --now libvirtd >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtqemud >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true
|
||||
systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -350,6 +594,12 @@ setup_lxc_services() {
|
||||
rc-service lxc start >/dev/null 2>&1 || true
|
||||
rc-update add lxcfs default >/dev/null 2>&1 || true
|
||||
rc-service lxcfs start >/dev/null 2>&1 || true
|
||||
rc-update add dbus default >/dev/null 2>&1 || true
|
||||
rc-service dbus start >/dev/null 2>&1 || true
|
||||
rc-update add libvirtd default >/dev/null 2>&1 || true
|
||||
rc-service libvirtd start >/dev/null 2>&1 || true
|
||||
rc-update add virtlogd default >/dev/null 2>&1 || true
|
||||
rc-service virtlogd start >/dev/null 2>&1 || true
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -416,7 +666,17 @@ download_release_if_needed() {
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
cp ./clicd /usr/local/bin/clicd
|
||||
if has_cmd systemctl; then
|
||||
systemctl stop clicd >/dev/null 2>&1 || true
|
||||
fi
|
||||
if has_cmd rc-service; then
|
||||
rc-service clicd stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
tmp_bin="/usr/local/bin/clicd.new.$$"
|
||||
cp ./clicd "$tmp_bin"
|
||||
chmod +x "$tmp_bin"
|
||||
mv -f "$tmp_bin" /usr/local/bin/clicd
|
||||
chmod +x /usr/local/bin/clicd
|
||||
log "Installed binary: /usr/local/bin/clicd"
|
||||
}
|
||||
@@ -424,8 +684,9 @@ install_binary() {
|
||||
install_systemd_service() {
|
||||
cat > /etc/systemd/system/clicd.service << 'EOF'
|
||||
[Unit]
|
||||
Description=CLICD - LXC Container Manager
|
||||
After=network.target lxc.service
|
||||
Description=CLICD - LXC/KVM Container Manager
|
||||
After=network.target lxc.service libvirtd.service virtqemud.service
|
||||
Wants=libvirtd.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -448,7 +709,7 @@ install_openrc_service() {
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name="CLICD"
|
||||
description="CLICD - LXC Container Manager"
|
||||
description="CLICD - LXC/KVM Container Manager"
|
||||
command="/usr/local/bin/clicd"
|
||||
command_args="server"
|
||||
command_background=true
|
||||
@@ -458,7 +719,7 @@ error_log="/var/log/clicd.err"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after lxc
|
||||
after lxc libvirtd
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -502,13 +763,13 @@ print_summary() {
|
||||
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
|
||||
fi
|
||||
echo ""
|
||||
echo "If no password is shown, this server already had /root/.clicd/config.json."
|
||||
echo "If no password is shown, this server already had /root/.clicd/config.db."
|
||||
echo "The existing admin password cannot be recovered from the bcrypt hash."
|
||||
}
|
||||
|
||||
install_dependencies
|
||||
configure_kernel_networking
|
||||
setup_lxc_services
|
||||
setup_runtime_services
|
||||
setup_subids
|
||||
try_enable_project_quota
|
||||
download_release_if_needed
|
||||
|
||||
Reference in New Issue
Block a user