mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
已测试LXC功能基本正常 KVM功能暂未进行测试
This commit is contained in:
@@ -0,0 +1,375 @@
|
|||||||
|
# CLICD 魔方财务对接模块
|
||||||
|
|
||||||
|
这是用于智简魔方 / IDCSMART 的 CLICD 服务器模块。模块通过 CLICD API 完成实例开通、删除、开关机、重启、重装、改密、资源变更、流量重置、NAT 端口映射管理、实例信息展示和 WebSSH 入口。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
clicd.php
|
||||||
|
README.md
|
||||||
|
handlers/
|
||||||
|
webssh.php
|
||||||
|
templates/
|
||||||
|
info.html
|
||||||
|
nat.html
|
||||||
|
```
|
||||||
|
|
||||||
|
安装时请保持目录结构不变,将整个 `clicd` 目录放入魔方服务器模块目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
public/plugins/servers/clicd/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器配置
|
||||||
|
|
||||||
|
在魔方后台添加服务器时,模块名称选择 `clicd`。
|
||||||
|
|
||||||
|
CLICD 面板地址建议使用 HTTPS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
主机名 = https://0.0.0.0:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以拆分填写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IP地址 = 0.0.0.0
|
||||||
|
端口 = 8999
|
||||||
|
secure = 开启
|
||||||
|
```
|
||||||
|
|
||||||
|
API Key 可以填写在以下任意一个字段中:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hash
|
||||||
|
密码
|
||||||
|
```
|
||||||
|
|
||||||
|
模块请求 CLICD 时会同时携带:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-API-Key: clicd_sk_xxxx
|
||||||
|
Authorization: Bearer clicd_sk_xxxx
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 产品配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `virtualization` | 虚拟化类型,`lxc` 或 `kvm` |
|
||||||
|
| `template_id` | CLICD 模板 / 镜像 ID |
|
||||||
|
| `vcpu` | CPU 核心数 |
|
||||||
|
| `cpu_percent` | CPU 使用率限制,`0` 表示不额外限制 |
|
||||||
|
| `ram_mb` | 内存,单位 MB |
|
||||||
|
| `disk_gb` | 系统盘,单位 GB |
|
||||||
|
| `network_bw_mbps` | 带宽,单位 Mbps |
|
||||||
|
| `traffic_mode` | `total` 总流量,或 `in_out` 入 / 出分开 |
|
||||||
|
| `monthly_traffic_gb` | 月流量 GB |
|
||||||
|
| `traffic_in_gb` | 入站流量 GB,`in_out` 模式使用 |
|
||||||
|
| `traffic_out_gb` | 出站流量 GB,`in_out` 模式使用 |
|
||||||
|
| `io_speed_mbps` | 磁盘 IO 限制,`0` 表示不限制 |
|
||||||
|
| `port_mapping_count` | 开通时分配的 NAT 端口数量,最小 2 |
|
||||||
|
| `snapshot_limit` | 快照配额 |
|
||||||
|
| `extra_ports` | 额外映射的容器端口,逗号分隔,例如 `80,443` |
|
||||||
|
| `assign_ipv6` | 开通时是否自动分配 IPv6 |
|
||||||
|
| `sync_expiry` | 是否同步魔方到期时间到 CLICD |
|
||||||
|
|
||||||
|
客户产品的 `domain` 会作为 CLICD 容器名称。模块会自动把不适合作为容器名的字符替换为 `-`。
|
||||||
|
|
||||||
|
## 开通后字段同步
|
||||||
|
|
||||||
|
开通、同步、重装、改密后,模块会从 CLICD 容器详情拉取最新信息并写回魔方主机表:
|
||||||
|
|
||||||
|
| 魔方字段 | 写入内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dedicatedip` | NAT 外网 IP,优先使用 API 返回的公网字段,否则使用服务器 IP |
|
||||||
|
| `username` | 固定写入 `root` |
|
||||||
|
| `password` | CLICD 返回的 SSH 密码,兼容魔方 `cmf_encrypt()` |
|
||||||
|
| `port` | CLICD 返回的 `ssh_port` |
|
||||||
|
| `domainstatus` | CLICD 状态为 `running` 时写 `Active`,否则写 `Suspended` |
|
||||||
|
|
||||||
|
如果接口返回的密码是 `***` 这类脱敏值,模块不会覆盖魔方里已有密码。
|
||||||
|
|
||||||
|
## 客户区页面
|
||||||
|
|
||||||
|
模块提供两个客户区选项卡:
|
||||||
|
|
||||||
|
```text
|
||||||
|
实例信息
|
||||||
|
NAT转发
|
||||||
|
```
|
||||||
|
|
||||||
|
客户区按钮提供:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSSH
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实例信息
|
||||||
|
|
||||||
|
实例信息页展示:
|
||||||
|
|
||||||
|
- 实例名称、运行状态、SSH 地址、IPv6
|
||||||
|
- CPU、内存、负载、磁盘圆环状态
|
||||||
|
- 月流量进度
|
||||||
|
- CPU 使用率、内存使用、网络流量、磁盘 IO 图表
|
||||||
|
- IPv4、SSH 端口、SSH 密码、资源配置、到期时间
|
||||||
|
|
||||||
|
图表数据通过客户区懒加载接口获取,不会强制刷新整个魔方页面。页面首次打开会加载一次数据,之后由用户选择是否自动刷新:
|
||||||
|
|
||||||
|
```text
|
||||||
|
不刷新
|
||||||
|
10 秒
|
||||||
|
1 分钟
|
||||||
|
5 分钟
|
||||||
|
10 分钟
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以点击“立即刷新”手动刷新一次。当前 CLICD 用量接口返回的是实时值,不是历史数组;图表曲线由客户区前端持续采样生成。若需要打开页面立即显示历史曲线,需要 CLICD 额外提供历史指标接口。
|
||||||
|
|
||||||
|
流量显示支持智能单位,小流量会显示 B / KB / MB,大流量显示 GB,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
370.5 KB / 100 GB
|
||||||
|
```
|
||||||
|
|
||||||
|
模块会优先调用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/containers/{name}/usage
|
||||||
|
GET /api/v1/containers/{name}/traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `/api/v1/containers/{name}/usage` 不可用,模块会在容器详情存在 `uuid` 时尝试兼容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/containers/{uuid}/usage
|
||||||
|
```
|
||||||
|
|
||||||
|
已兼容的常见用量字段包括:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cpu_usage_pct
|
||||||
|
memory_usage_bytes
|
||||||
|
disk_usage_bytes
|
||||||
|
network_rx_bps
|
||||||
|
network_tx_bps
|
||||||
|
disk_read_bps
|
||||||
|
disk_write_bps
|
||||||
|
rx_used_bytes
|
||||||
|
tx_used_bytes
|
||||||
|
total_used_bytes
|
||||||
|
limit_gb
|
||||||
|
used_pct
|
||||||
|
```
|
||||||
|
|
||||||
|
## NAT 转发
|
||||||
|
|
||||||
|
NAT 转发是独立页面,支持:
|
||||||
|
|
||||||
|
- 查看端口映射
|
||||||
|
- 获取随机可用端口
|
||||||
|
- 添加端口映射
|
||||||
|
- 修改端口映射
|
||||||
|
- 删除端口映射
|
||||||
|
|
||||||
|
删除端口映射时使用页面内确认弹窗,不使用浏览器自带确认框。
|
||||||
|
|
||||||
|
使用的 CLICD API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/containers/{id|uuid|name}
|
||||||
|
GET /api/v1/containers/{id}/random-port
|
||||||
|
POST /api/v1/containers/{id}/port-mappings
|
||||||
|
PUT /api/v1/containers/{id}/port-mappings/{index}
|
||||||
|
DELETE /api/v1/containers/{id}/port-mappings/{index}
|
||||||
|
```
|
||||||
|
|
||||||
|
添加 / 修改 NAT 映射时必须使用 JSON 请求体,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"container_port": 8080,
|
||||||
|
"host_port": 61320,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"description": "HTTP"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSSH
|
||||||
|
|
||||||
|
WebSSH 按钮会调用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/ssh-ticket
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"container_name": "example-vm"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
接口返回 60 秒有效票据后,模块会打开本地 handler:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/plugins/servers/clicd/handlers/webssh.php
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器会从该页面直连 CLICD:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wss://0.0.0.0:8999/api/ssh?container=example-vm
|
||||||
|
Sec-WebSocket-Protocol: clicd-ticket.xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:WebSSH 受浏览器安全策略和 CLICD 后端 Origin 校验影响。魔方客户区通常是 HTTPS,因此 CLICD 面板也必须启用 HTTPS/WSS。请把魔方服务器配置里的 `主机名` 改为 `https://0.0.0.0:8999`,或把 `secure` 设为 `开启`。
|
||||||
|
|
||||||
|
新版 CLICD 已支持 WebSSH Origin 放行。部署时需要在 CLICD 后端把魔方财务客户区域名加入 WebSSH Origin 白名单,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://www.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 WebSSH 页面显示 `WebSocket error`、`Disconnected code=1006`,但直接以 CLICD 自身 Origin 测试能返回 `101 Switching Protocols`,通常说明 CLICD 后端未放行魔方客户区域名的 WebSocket Origin。此时请检查 CLICD 的 WebSSH Origin 白名单配置;前端页面无法伪造浏览器 Origin。
|
||||||
|
|
||||||
|
## 支持的魔方操作
|
||||||
|
|
||||||
|
| 魔方操作 | CLICD API |
|
||||||
|
| --- | --- |
|
||||||
|
| 连接测试 | `GET /api/v1/dashboard` |
|
||||||
|
| 开通 | `POST /api/v1/containers` |
|
||||||
|
| 删除 | `DELETE /api/v1/containers/{name}/delete` |
|
||||||
|
| 开机 | `POST /api/v1/containers/{name}/start` |
|
||||||
|
| 关机 | `POST /api/v1/containers/{name}/stop` |
|
||||||
|
| 重启 | `POST /api/v1/containers/{name}/restart` |
|
||||||
|
| 重装 | `POST /api/v1/containers/{name}/reinstall` |
|
||||||
|
| 改密 | `POST /api/v1/containers/{name}/reset-password` |
|
||||||
|
| 重置流量 | `POST /api/v1/containers/{name}/traffic-reset` |
|
||||||
|
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
|
||||||
|
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
|
||||||
|
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
|
||||||
|
| WebSSH | `POST /api/v1/ssh-ticket` |
|
||||||
|
|
||||||
|
## 建议 API 权限
|
||||||
|
|
||||||
|
API Key 至少需要以下权限,具体名称以 CLICD 后端实际权限系统为准:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dashboard:read
|
||||||
|
container:read
|
||||||
|
container:create
|
||||||
|
container:power
|
||||||
|
container:delete
|
||||||
|
container:reinstall
|
||||||
|
container:password
|
||||||
|
container:traffic
|
||||||
|
container:resize
|
||||||
|
container:port
|
||||||
|
task:read
|
||||||
|
ssh-ticket:create
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 API Key 使用 `*` 或 `admin:*`,通常可以覆盖上述权限。
|
||||||
|
|
||||||
|
## 建议先测试的 curl
|
||||||
|
|
||||||
|
连接测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
容器详情:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/containers/example-vm
|
||||||
|
```
|
||||||
|
|
||||||
|
资源用量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/containers/example-vm/usage
|
||||||
|
```
|
||||||
|
|
||||||
|
流量统计:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
https://0.0.0.0:8999/api/v1/containers/example-vm/traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 NAT:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --location --request PUT \
|
||||||
|
"https://0.0.0.0:8999/api/v1/containers/10/port-mappings/1" \
|
||||||
|
--header "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
--header "Authorization: Bearer clicd_sk_xxxx" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 WebSSH 票据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --location --request POST \
|
||||||
|
"https://0.0.0.0:8999/api/v1/ssh-ticket" \
|
||||||
|
--header "X-API-Key: clicd_sk_xxxx" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data-raw '{"container_name":"example-vm"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### NAT 修改不生效
|
||||||
|
|
||||||
|
确认请求体必须是 JSON,不要使用 `multipart/form-data`。正确请求头:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图表刚打开只有一条横线
|
||||||
|
|
||||||
|
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
|
||||||
|
|
||||||
|
### 流量显示为 0
|
||||||
|
|
||||||
|
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
|
||||||
|
|
||||||
|
### WebSSH 打不开或提示不安全 WebSocket
|
||||||
|
|
||||||
|
请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server_host = https://0.0.0.0:8999
|
||||||
|
```
|
||||||
|
|
||||||
|
如果仍然使用 `http://`,模块会生成 `ws://` 地址,HTTPS 客户区页面会被浏览器拦截。
|
||||||
|
|
||||||
|
如果 WSS 证书正常但仍返回 `Forbidden` 或浏览器显示 `code=1006`,请检查 CLICD 的 WebSSH Origin 白名单。新版 CLICD 已支持放行魔方财务域名,需要把魔方客户区访问域名完整加入白名单,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://www.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
注意需要填写浏览器实际访问魔方客户区时的协议和域名,`http` / `https`、带不带 `www` 都要与实际访问地址一致。
|
||||||
|
|
||||||
|
### 开通后魔方里的 IP、端口、密码不对
|
||||||
|
|
||||||
|
执行“同步状态”或重装 / 改密后,模块会重新拉取容器详情。请确认 CLICD 容器详情接口能返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ssh_port
|
||||||
|
ssh_password
|
||||||
|
status
|
||||||
|
```
|
||||||
|
|
||||||
|
公网 IP 优先使用 `nat_public_ip/public_ip/host_ip/external_ip/node_ip/nat_host` 等字段;如果接口没有返回,则使用魔方服务器配置的 IP。
|
||||||
+1368
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,384 @@
|
|||||||
|
<?php
|
||||||
|
$ws = isset($_GET['ws']) ? (string)$_GET['ws'] : (isset($_GET['amp;ws']) ? (string)$_GET['amp;ws'] : '');
|
||||||
|
$protocol = isset($_GET['protocol']) ? (string)$_GET['protocol'] : (isset($_GET['amp;protocol']) ? (string)$_GET['amp;protocol'] : '');
|
||||||
|
$container = isset($_GET['container']) ? (string)$_GET['container'] : (isset($_GET['amp;container']) ? (string)$_GET['amp;container'] : '');
|
||||||
|
|
||||||
|
if ($ws === '' || $protocol === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Missing WebSSH parameters\n";
|
||||||
|
echo "Received query: " . ($_SERVER['QUERY_STRING'] ?? '') . "\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WebSSH</title>
|
||||||
|
<style>
|
||||||
|
html,body{height:100%;margin:0;background:#0b1020;color:#e5e7eb;font-family:Consolas,Menlo,monospace}
|
||||||
|
body{cursor:text}
|
||||||
|
.bar{height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:#111827;border-bottom:1px solid #243047}
|
||||||
|
.dot{width:9px;height:9px;border-radius:50%;background:#f59e0b}
|
||||||
|
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}
|
||||||
|
.title{font-size:14px;color:#cbd5e1;flex:1}
|
||||||
|
.tools{display:flex;align-items:center;gap:8px;font-size:12px;color:#94a3b8;flex-wrap:wrap;justify-content:flex-end}
|
||||||
|
.tools select{height:26px;background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:4px}
|
||||||
|
.tools button{height:26px;border:1px solid #334155;background:#0f172a;color:#cbd5e1;border-radius:4px;padding:0 8px;cursor:pointer}
|
||||||
|
#keyhint{min-width:44px;text-align:right}
|
||||||
|
#iostat{min-width:120px;text-align:right}
|
||||||
|
#term{height:calc(100% - 89px);box-sizing:border-box;padding:14px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.45;outline:none}
|
||||||
|
.inputbar{height:44px;display:flex;align-items:center;gap:8px;padding:6px 10px;box-sizing:border-box;background:#111827;border-top:1px solid #243047}
|
||||||
|
#cmd{flex:1;height:30px;background:#020617;color:#e5e7eb;border:1px solid #334155;border-radius:4px;padding:0 8px;font:14px Consolas,Menlo,monospace;outline:none}
|
||||||
|
#sendcmd{height:30px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
|
||||||
|
.hint{color:#94a3b8}
|
||||||
|
.meta{color:#94a3b8}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bar">
|
||||||
|
<span id="state" class="dot"></span>
|
||||||
|
<span class="title">WebSSH <?php echo htmlspecialchars($container, ENT_QUOTES, 'UTF-8'); ?></span>
|
||||||
|
<span class="tools">
|
||||||
|
<span>发送模式</span>
|
||||||
|
<select id="send-mode">
|
||||||
|
<option value="raw" selected>raw</option>
|
||||||
|
<option value="binary">binary</option>
|
||||||
|
<option value="json-input">json input</option>
|
||||||
|
<option value="json-data">json data</option>
|
||||||
|
<option value="json-stdin">json stdin</option>
|
||||||
|
</select>
|
||||||
|
<button id="send-enter" type="button">回车</button>
|
||||||
|
<span id="iostat">S0 R0</span>
|
||||||
|
<span id="keyhint"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="term" tabindex="0"><span class="hint">正在连接...</span></div>
|
||||||
|
<div class="inputbar">
|
||||||
|
<input id="cmd" type="text" autocomplete="off" spellcheck="false" placeholder="在这里输入命令,例如 ls -la">
|
||||||
|
<button id="sendcmd" type="button">发送</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var wsUrl = <?php echo json_encode($ws, JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
var protocol = <?php echo json_encode($protocol, JSON_UNESCAPED_SLASHES); ?>;
|
||||||
|
var term = document.getElementById('term');
|
||||||
|
var state = document.getElementById('state');
|
||||||
|
var modeSelect = document.getElementById('send-mode');
|
||||||
|
var keyhint = document.getElementById('keyhint');
|
||||||
|
var iostat = document.getElementById('iostat');
|
||||||
|
var sendEnter = document.getElementById('send-enter');
|
||||||
|
var cmd = document.getElementById('cmd');
|
||||||
|
var sendcmd = document.getElementById('sendcmd');
|
||||||
|
var socket;
|
||||||
|
var hintTimer;
|
||||||
|
var sentCount = 0;
|
||||||
|
var recvCount = 0;
|
||||||
|
var decoder = window.TextDecoder ? new TextDecoder('utf-8') : null;
|
||||||
|
var termLines = [''];
|
||||||
|
var cursorRow = 0;
|
||||||
|
var cursorCol = 0;
|
||||||
|
var maxLines = 2000;
|
||||||
|
|
||||||
|
function append(text) {
|
||||||
|
writeTerminal(stripTerminalControls(String(text || '')));
|
||||||
|
renderTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTerminal() {
|
||||||
|
termLines = [''];
|
||||||
|
cursorRow = 0;
|
||||||
|
cursorCol = 0;
|
||||||
|
renderTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTerminalControls(text) {
|
||||||
|
return text
|
||||||
|
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
||||||
|
.replace(/\x1b\[(?:2J|H)/g, '\f')
|
||||||
|
.replace(/\x1b\[[0-?]*[ -/]*K/g, '\v')
|
||||||
|
.replace(/\ufffd\[[0-?]*[ -/]*K/g, '\v')
|
||||||
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||||
|
.replace(/\ufffd\[[0-?]*[ -/]*[@-~]/g, '')
|
||||||
|
.replace(/\x1b[()][A-Za-z0-9]/g, '')
|
||||||
|
.replace(/\x1b[@-Z\\-_]/g, '')
|
||||||
|
.replace(/[\x00-\x08\x0e-\x1f\x7f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLine() {
|
||||||
|
while (cursorRow >= termLines.length) {
|
||||||
|
termLines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTerminal() {
|
||||||
|
if (termLines.length <= maxLines) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var overflow = termLines.length - maxLines;
|
||||||
|
termLines.splice(0, overflow);
|
||||||
|
cursorRow = Math.max(0, cursorRow - overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTerminal(text) {
|
||||||
|
for (var i = 0; i < text.length; i++) {
|
||||||
|
var ch = text.charAt(i);
|
||||||
|
if (ch === '\f') {
|
||||||
|
termLines = [''];
|
||||||
|
cursorRow = 0;
|
||||||
|
cursorCol = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\v') {
|
||||||
|
ensureLine();
|
||||||
|
termLines[cursorRow] = termLines[cursorRow].slice(0, cursorCol);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\r') {
|
||||||
|
cursorCol = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\n') {
|
||||||
|
cursorRow++;
|
||||||
|
cursorCol = 0;
|
||||||
|
ensureLine();
|
||||||
|
trimTerminal();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\b') {
|
||||||
|
cursorCol = Math.max(0, cursorCol - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\t') {
|
||||||
|
var spaces = 4 - (cursorCol % 4);
|
||||||
|
for (var s = 0; s < spaces; s++) {
|
||||||
|
writePrintable(' ');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writePrintable(ch);
|
||||||
|
}
|
||||||
|
trimTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePrintable(ch) {
|
||||||
|
ensureLine();
|
||||||
|
var line = termLines[cursorRow];
|
||||||
|
if (cursorCol > line.length) {
|
||||||
|
line += new Array(cursorCol - line.length + 1).join(' ');
|
||||||
|
}
|
||||||
|
termLines[cursorRow] = line.slice(0, cursorCol) + ch + line.slice(cursorCol + 1);
|
||||||
|
cursorCol++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTerminal() {
|
||||||
|
term.textContent = termLines.join('\n');
|
||||||
|
term.scrollTop = term.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(cls, text) {
|
||||||
|
state.className = 'dot ' + cls;
|
||||||
|
append(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIoStatus() {
|
||||||
|
if (!iostat) return;
|
||||||
|
var stateText = socket ? ['CONNECTING','OPEN','CLOSING','CLOSED'][socket.readyState] : '-';
|
||||||
|
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(wsUrl, protocol);
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
} catch (e) {
|
||||||
|
setState('err', '\nWebSocket 创建失败:' + e.message + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onopen = function(){
|
||||||
|
clearTerminal();
|
||||||
|
setState('ok', '已连接。\r\n');
|
||||||
|
updateIoStatus();
|
||||||
|
if (cmd) cmd.focus();
|
||||||
|
};
|
||||||
|
socket.onmessage = function(event){
|
||||||
|
recvCount++;
|
||||||
|
updateIoStatus();
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
handleIncomingText(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
handleIncomingText(decodeIncoming(event.data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.Blob && event.data instanceof Blob) {
|
||||||
|
event.data.arrayBuffer().then(function(buffer){
|
||||||
|
handleIncomingText(decodeIncoming(buffer));
|
||||||
|
}).catch(function(){
|
||||||
|
append('\n[WebSSH] 无法解码服务端返回内容。\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onerror = function(){
|
||||||
|
setState('err', '\nWebSocket 连接错误,请检查 HTTPS 证书、WSS 服务、Origin 策略和票据有效期。\n');
|
||||||
|
};
|
||||||
|
socket.onclose = function(event){
|
||||||
|
updateIoStatus();
|
||||||
|
setState('err', '\n连接已断开。code=' + event.code + ' reason=' + (event.reason || '-') + ' clean=' + event.wasClean + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
function flashKey(text) {
|
||||||
|
if (!keyhint) return;
|
||||||
|
keyhint.textContent = text;
|
||||||
|
window.clearTimeout(hintTimer);
|
||||||
|
hintTimer = window.setTimeout(function(){ keyhint.textContent = ''; }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeIncoming(buffer) {
|
||||||
|
if (decoder) {
|
||||||
|
return decoder.decode(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
var bytes = new Uint8Array(buffer);
|
||||||
|
var text = '';
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
text += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(escape(text));
|
||||||
|
} catch (e) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIncomingText(text) {
|
||||||
|
append(text);
|
||||||
|
if (text.indexOf('SSH shell ready') !== -1) {
|
||||||
|
window.setTimeout(function(){ send('\r'); }, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsPayload(data) {
|
||||||
|
var mode = modeSelect ? modeSelect.value : 'raw';
|
||||||
|
if (mode === 'raw') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (mode === 'binary') {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (mode === 'json-data') {
|
||||||
|
return JSON.stringify({type:'data', data:data});
|
||||||
|
}
|
||||||
|
if (mode === 'json-stdin') {
|
||||||
|
return JSON.stringify({type:'stdin', data:data});
|
||||||
|
}
|
||||||
|
return JSON.stringify({type:'input', data:data});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(data) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
socket.send(wsPayload(data));
|
||||||
|
sentCount++;
|
||||||
|
updateIoStatus();
|
||||||
|
flashKey(data === '\r' ? '回车' : data === '\x7f' ? '退格' : data.length > 1 ? data.length + ' 字符' : data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyToData(e) {
|
||||||
|
if (e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
|
||||||
|
var code = e.key.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 64 && code <= 95) {
|
||||||
|
return String.fromCharCode(code - 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var map = {
|
||||||
|
Enter: '\r',
|
||||||
|
Backspace: '\x7f',
|
||||||
|
Tab: '\t',
|
||||||
|
Escape: '\x1b',
|
||||||
|
ArrowUp: '\x1b[A',
|
||||||
|
ArrowDown: '\x1b[B',
|
||||||
|
ArrowRight: '\x1b[C',
|
||||||
|
ArrowLeft: '\x1b[D',
|
||||||
|
Delete: '\x1b[3~',
|
||||||
|
Home: '\x1b[H',
|
||||||
|
End: '\x1b[F',
|
||||||
|
PageUp: '\x1b[5~',
|
||||||
|
PageDown: '\x1b[6~'
|
||||||
|
};
|
||||||
|
if (map[e.key]) {
|
||||||
|
return map[e.key];
|
||||||
|
}
|
||||||
|
if (!e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
|
||||||
|
return e.key;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
if (e.target === cmd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var data = keyToData(e);
|
||||||
|
if (data !== null && send(data)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('paste', function(e){
|
||||||
|
if (e.target === cmd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||||
|
if (text && send(text)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', function(){
|
||||||
|
if (cmd) cmd.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendCommandLine() {
|
||||||
|
if (!cmd) return;
|
||||||
|
var value = cmd.value;
|
||||||
|
if (value === '') {
|
||||||
|
send('\r');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (send(value + '\r')) {
|
||||||
|
cmd.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendcmd) {
|
||||||
|
sendcmd.addEventListener('click', sendCommandLine);
|
||||||
|
}
|
||||||
|
if (sendEnter) {
|
||||||
|
sendEnter.addEventListener('click', function(){
|
||||||
|
send('\r');
|
||||||
|
if (cmd) cmd.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cmd) {
|
||||||
|
cmd.addEventListener('keydown', function(e){
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
sendCommandLine();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
|
||||||
|
send('\x03');
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.setInterval(updateIoStatus, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<style>
|
||||||
|
.clicd-info{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
|
||||||
|
.clicd-info *{box-sizing:border-box}
|
||||||
|
.clicd-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:12px}
|
||||||
|
.clicd-mini{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:10px}
|
||||||
|
.clicd-mini-label{font-size:12px;color:#6b7280;margin-bottom:4px}
|
||||||
|
.clicd-mini-value{font-size:16px;font-weight:600;color:#111827;word-break:break-all}
|
||||||
|
.clicd-section{background:#fff;border:1px solid #e5e7eb;border-radius:6px;margin-top:12px;padding:14px}
|
||||||
|
.clicd-section-title{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:15px;font-weight:700;margin-bottom:12px;color:#111827;min-width:0;flex-wrap:wrap}
|
||||||
|
.clicd-section-title>span:first-child{min-width:0}
|
||||||
|
.clicd-refresh{display:flex;align-items:center;justify-content:flex-end;gap:8px;font-size:12px;color:#6b7280;font-weight:400;flex-wrap:wrap;min-width:0;max-width:100%}
|
||||||
|
.clicd-refresh label{display:inline-flex;align-items:center;gap:4px;min-width:0;white-space:nowrap}
|
||||||
|
.clicd-refresh-select{height:28px;border:1px solid #d1d5db;border-radius:4px;background:#fff;color:#374151;padding:3px 6px;font-size:12px}
|
||||||
|
.clicd-refresh-btn{height:28px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:3px 8px;font-size:12px;cursor:pointer;white-space:nowrap;max-width:96px;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.clicd-refresh-btn[disabled]{opacity:.6;cursor:not-allowed}
|
||||||
|
.clicd-gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
|
||||||
|
.clicd-gauge{display:flex;align-items:center;gap:12px;min-height:92px}
|
||||||
|
.clicd-ring{--p:0%;width:78px;height:78px;border-radius:50%;background:conic-gradient(#2f80ed var(--p),#e5e7eb 0);display:grid;place-items:center;flex:0 0 auto;position:relative}
|
||||||
|
.clicd-ring:before{content:"";width:66px;height:66px;border-radius:50%;background:#fff;position:absolute}
|
||||||
|
.clicd-ring span{position:relative;display:inline-flex;align-items:center;justify-content:center;max-width:62px;min-width:0;font-size:17px;font-weight:700;line-height:1;color:#111827;white-space:nowrap;text-align:center;background:#fff;border-radius:3px;padding:0 1px}
|
||||||
|
.clicd-ring[data-tight="1"] span{font-size:15px}
|
||||||
|
.clicd-ring[data-tight="2"] span{font-size:14px}
|
||||||
|
.clicd-gauge-title{font-weight:700;color:#111827;margin-bottom:4px}
|
||||||
|
.clicd-gauge-sub{font-size:12px;color:#6b7280;line-height:1.45}
|
||||||
|
.clicd-progress{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden}
|
||||||
|
.clicd-progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,#2f80ed,#10b981);transition:width .25s ease}
|
||||||
|
.clicd-traffic-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;margin-top:8px;color:#374151;min-width:0}
|
||||||
|
.clicd-traffic-row>div{min-width:0;word-break:break-word}
|
||||||
|
.clicd-charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
|
||||||
|
.clicd-chart{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff;min-height:190px;min-width:0;overflow:hidden}
|
||||||
|
.clicd-chart-title{display:flex;justify-content:space-between;gap:8px;align-items:center;font-weight:700;margin-bottom:8px;color:#111827;min-width:0;flex-wrap:wrap}
|
||||||
|
.clicd-chart-value{font-size:12px;color:#6b7280;font-weight:400;white-space:normal;overflow-wrap:anywhere;text-align:right}
|
||||||
|
.clicd-chart canvas{width:100%;height:132px;display:block}
|
||||||
|
.clicd-table{width:100%;border-collapse:collapse;background:#fff}
|
||||||
|
.clicd-table th,.clicd-table td{border:1px solid #e5e7eb;padding:8px;text-align:left}
|
||||||
|
.clicd-table th{width:16%;background:#f9fafb;color:#374151;font-weight:600}
|
||||||
|
.clicd-debug{display:none;margin-top:10px;padding:8px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;border-radius:6px;font-size:12px}
|
||||||
|
@media (max-width:640px){
|
||||||
|
.clicd-info{padding:10px}
|
||||||
|
.clicd-section-title{align-items:flex-start}
|
||||||
|
.clicd-refresh{justify-content:flex-start;width:100%}
|
||||||
|
.clicd-charts{grid-template-columns:1fr}
|
||||||
|
.clicd-table th,.clicd-table td{display:block;width:100%}
|
||||||
|
.clicd-ring{width:70px;height:70px}
|
||||||
|
.clicd-ring:before{width:60px;height:60px}
|
||||||
|
.clicd-ring span{max-width:56px;font-size:15px}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="clicd-info" data-clicd-info-root="1">
|
||||||
|
<div class="clicd-head">
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">实例名称</div>
|
||||||
|
<div class="clicd-mini-value">{$container.name|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">运行状态</div>
|
||||||
|
<div class="clicd-mini-value">{$status_text|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">SSH 地址</div>
|
||||||
|
<div class="clicd-mini-value">{$ssh_host|default='-'}:{$ssh_port|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-mini">
|
||||||
|
<div class="clicd-mini-label">IPv6</div>
|
||||||
|
<div class="clicd-mini-value">{$ipv6|default='-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-section">
|
||||||
|
<div class="clicd-section-title">
|
||||||
|
<span>状态</span>
|
||||||
|
<span class="clicd-refresh">
|
||||||
|
<span>更新于 <span data-clicd-info="chart_time">-</span></span>
|
||||||
|
<label>
|
||||||
|
自动刷新
|
||||||
|
<select class="clicd-refresh-select" id="clicd-info-refresh">
|
||||||
|
<option value="0" selected>不刷新</option>
|
||||||
|
<option value="10000">10 秒</option>
|
||||||
|
<option value="60000">1 分钟</option>
|
||||||
|
<option value="300000">5 分钟</option>
|
||||||
|
<option value="600000">10 分钟</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="clicd-refresh-btn" type="button" id="clicd-info-refresh-now">立即刷新</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauges">
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="cpu_percent"><span><span data-clicd-info="cpu_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">CPU</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="cpu_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="mem_percent"><span><span data-clicd-info="mem_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">内存</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="mem_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="load_percent"><span><span data-clicd-info="load_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">负载</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="load_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-gauge">
|
||||||
|
<div class="clicd-ring" data-gauge="disk_percent"><span><span data-clicd-info="disk_percent">0</span>%</span></div>
|
||||||
|
<div>
|
||||||
|
<div class="clicd-gauge-title">磁盘</div>
|
||||||
|
<div class="clicd-gauge-sub" data-clicd-info="disk_detail">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:14px">
|
||||||
|
<div class="clicd-traffic-row">
|
||||||
|
<div>月流量</div>
|
||||||
|
<div><span data-clicd-info="traffic_used_text">{$traffic_used_text|default='-'}</span> / <span data-clicd-info="traffic_limit_text">{$traffic_limit_text|default='-'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-progress"><span data-progress="traffic_percent"></span></div>
|
||||||
|
<div class="clicd-traffic-row" style="font-size:12px;color:#6b7280">
|
||||||
|
<div>入站 <span data-clicd-info="traffic_in_text">{$traffic_in_text|default='-'}</span></div>
|
||||||
|
<div>出站 <span data-clicd-info="traffic_out_text">{$traffic_out_text|default='-'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-section">
|
||||||
|
<div class="clicd-section-title"><span>统计信息</span></div>
|
||||||
|
<div class="clicd-charts">
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">CPU 使用率 <span class="clicd-chart-value" data-clicd-info="cpu_detail">-</span></div>
|
||||||
|
<canvas data-chart="cpu_percent"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">内存使用 <span class="clicd-chart-value" data-clicd-info="mem_detail">-</span></div>
|
||||||
|
<canvas data-chart="mem_percent"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">网络流量 <span class="clicd-chart-value"><span data-clicd-info="net_in_rate">0 B/s</span> / <span data-clicd-info="net_out_rate">0 B/s</span></span></div>
|
||||||
|
<canvas data-chart="network"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-chart">
|
||||||
|
<div class="clicd-chart-title">磁盘 IO <span class="clicd-chart-value"><span data-clicd-info="disk_read_rate">0 B/s</span> / <span data-clicd-info="disk_write_rate">0 B/s</span></span></div>
|
||||||
|
<canvas data-chart="diskio"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-section">
|
||||||
|
<div class="clicd-section-title"><span>实例信息</span></div>
|
||||||
|
<table class="clicd-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>IPv4</th><td>{$ipv4|default='-'}</td>
|
||||||
|
<th>用户名</th><td>root</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>SSH 端口</th><td>{$ssh_port|default='-'}</td>
|
||||||
|
<th>SSH 密码</th><td>{$ssh_password|default='-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>CPU</th><td>{$vcpu|default='-'} 核</td>
|
||||||
|
<th>内存</th><td>{$ram_mb|default='-'} MB</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>硬盘</th><td>{$disk_gb|default='-'} GB</td>
|
||||||
|
<th>带宽</th><td>{$bandwidth|default='-'} Mbps</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>到期时间</th><td colspan="3">{$expires_at|default='-'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-debug" id="clicd-info-debug"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var root = document.querySelector('[data-clicd-info-root="1"]:not([data-info-bound="1"])');
|
||||||
|
if (!root) return;
|
||||||
|
root.setAttribute('data-info-bound', '1');
|
||||||
|
|
||||||
|
var history = {
|
||||||
|
cpu_percent: [],
|
||||||
|
mem_percent: [],
|
||||||
|
network_in: [],
|
||||||
|
network_out: [],
|
||||||
|
disk_read: [],
|
||||||
|
disk_write: []
|
||||||
|
};
|
||||||
|
var maxPoints = 18;
|
||||||
|
var refreshTimer = null;
|
||||||
|
|
||||||
|
function endpoint() {
|
||||||
|
return "{$MODULE_CUSTOM_API}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value) {
|
||||||
|
var n = parseFloat(value);
|
||||||
|
return isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(name, value) {
|
||||||
|
history[name].push(number(value));
|
||||||
|
if (history[name].length > maxPoints) history[name].shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(key, value) {
|
||||||
|
root.querySelectorAll('[data-clicd-info="' + key + '"]').forEach(function(node){
|
||||||
|
if (typeof value !== 'object') node.textContent = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGauge(key, value) {
|
||||||
|
var pct = Math.max(0, Math.min(100, number(value)));
|
||||||
|
root.querySelectorAll('[data-gauge="' + key + '"]').forEach(function(node){
|
||||||
|
node.style.setProperty('--p', pct + '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitGaugeText() {
|
||||||
|
root.querySelectorAll('.clicd-ring').forEach(function(ring){
|
||||||
|
var label = ring.querySelector('span');
|
||||||
|
if (!label) return;
|
||||||
|
ring.removeAttribute('data-tight');
|
||||||
|
if (label.scrollWidth > label.clientWidth) {
|
||||||
|
ring.setAttribute('data-tight', '1');
|
||||||
|
}
|
||||||
|
if (label.scrollWidth > label.clientWidth) {
|
||||||
|
ring.setAttribute('data-tight', '2');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(key, value) {
|
||||||
|
var pct = Math.max(0, Math.min(100, number(value)));
|
||||||
|
root.querySelectorAll('[data-progress="' + key + '"]').forEach(function(node){
|
||||||
|
node.style.width = pct + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(canvas, series, colors, maxValue) {
|
||||||
|
if (!canvas || !canvas.getContext) return;
|
||||||
|
var rect = canvas.getBoundingClientRect();
|
||||||
|
var ratio = window.devicePixelRatio || 1;
|
||||||
|
var width = Math.max(220, Math.floor(rect.width || canvas.clientWidth || 220));
|
||||||
|
var height = Math.max(120, Math.floor(rect.height || canvas.clientHeight || 132));
|
||||||
|
if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
|
||||||
|
canvas.width = width * ratio;
|
||||||
|
canvas.height = height * ratio;
|
||||||
|
}
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (var i = 1; i < 4; i++) {
|
||||||
|
var y = Math.round((height / 4) * i);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
series.forEach(function(values, idx){
|
||||||
|
if (!values.length) return;
|
||||||
|
var color = colors[idx] || '#2f80ed';
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (values.length === 1) {
|
||||||
|
var singleY = height - (Math.max(0, Math.min(maxValue, number(values[0]))) / maxValue) * (height - 6) - 3;
|
||||||
|
ctx.moveTo(0, singleY);
|
||||||
|
ctx.lineTo(width, singleY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(width - 8, singleY, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
values.forEach(function(value, i){
|
||||||
|
var x = values.length <= 1 ? width : (i / (values.length - 1)) * width;
|
||||||
|
var y = height - (Math.max(0, Math.min(maxValue, number(value))) / maxValue) * (height - 6) - 3;
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw() {
|
||||||
|
draw(root.querySelector('[data-chart="cpu_percent"]'), [history.cpu_percent], ['#2f80ed'], 100);
|
||||||
|
draw(root.querySelector('[data-chart="mem_percent"]'), [history.mem_percent], ['#10b981'], 100);
|
||||||
|
var netMax = Math.max(1, Math.max.apply(null, history.network_in.concat(history.network_out, [1])));
|
||||||
|
draw(root.querySelector('[data-chart="network"]'), [history.network_in, history.network_out], ['#2f80ed', '#f59e0b'], netMax);
|
||||||
|
var ioMax = Math.max(1, Math.max.apply(null, history.disk_read.concat(history.disk_write, [1])));
|
||||||
|
draw(root.querySelector('[data-chart="diskio"]'), [history.disk_read, history.disk_write], ['#10b981', '#ef4444'], ioMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInfoError(text) {
|
||||||
|
var debug = document.getElementById('clicd-info-debug');
|
||||||
|
if (debug) {
|
||||||
|
debug.style.display = 'block';
|
||||||
|
debug.textContent = text || 'info load failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInfo() {
|
||||||
|
var refreshNow = document.getElementById('clicd-info-refresh-now');
|
||||||
|
if (refreshNow) refreshNow.disabled = true;
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('id', '{$service_id}');
|
||||||
|
body.set('func', 'infoData');
|
||||||
|
fetch(endpoint(), {
|
||||||
|
method:'POST',
|
||||||
|
headers:{
|
||||||
|
'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Authorization':'JWT {$Think.get.jwt}'
|
||||||
|
},
|
||||||
|
credentials:'same-origin',
|
||||||
|
body: body.toString()
|
||||||
|
})
|
||||||
|
.then(function(res){ return res.json(); })
|
||||||
|
.then(function(json){
|
||||||
|
if (!json || (json.status !== 200 && json.status !== 'success') || !json.data) {
|
||||||
|
showInfoError(json && json.msg ? json.msg : 'info load failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var data = json.data;
|
||||||
|
Object.keys(data).forEach(function(key){ setText(key, data[key]); });
|
||||||
|
['cpu_percent','mem_percent','load_percent','disk_percent'].forEach(function(key){ setGauge(key, data[key]); });
|
||||||
|
fitGaugeText();
|
||||||
|
setProgress('traffic_percent', data.traffic_percent);
|
||||||
|
push('cpu_percent', data.cpu_percent);
|
||||||
|
push('mem_percent', data.mem_percent);
|
||||||
|
push('network_in', data.net_in_bps);
|
||||||
|
push('network_out', data.net_out_bps);
|
||||||
|
push('disk_read', data.disk_read_bps);
|
||||||
|
push('disk_write', data.disk_write_bps);
|
||||||
|
redraw();
|
||||||
|
})
|
||||||
|
.catch(function(error){
|
||||||
|
showInfoError(error && error.message ? error.message : 'info request failed');
|
||||||
|
})
|
||||||
|
.finally(function(){
|
||||||
|
if (refreshNow) refreshNow.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInfo();
|
||||||
|
var refreshSelect = document.getElementById('clicd-info-refresh');
|
||||||
|
var refreshNow = document.getElementById('clicd-info-refresh-now');
|
||||||
|
if (refreshNow) {
|
||||||
|
refreshNow.addEventListener('click', loadInfo);
|
||||||
|
}
|
||||||
|
if (refreshSelect) {
|
||||||
|
refreshSelect.addEventListener('change', function(){
|
||||||
|
if (refreshTimer) {
|
||||||
|
window.clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
var ms = number(refreshSelect.value);
|
||||||
|
if (ms > 0) {
|
||||||
|
loadInfo();
|
||||||
|
refreshTimer = window.setInterval(loadInfo, ms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', function(){ window.setTimeout(function(){ fitGaugeText(); redraw(); }, 50); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<style>
|
||||||
|
.clicd-nat-panel{font-size:14px;color:#1f2937}
|
||||||
|
.clicd-nat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
|
||||||
|
.clicd-nat-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}
|
||||||
|
.clicd-nat-label{color:#6b7280;font-size:12px;margin-bottom:4px}
|
||||||
|
.clicd-nat-value{font-size:18px;font-weight:600;word-break:break-all}
|
||||||
|
.clicd-nat-title{font-weight:600;margin:18px 0 8px}
|
||||||
|
.clicd-nat-muted{color:#6b7280}
|
||||||
|
.clicd-nat-form{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px;margin-top:8px}
|
||||||
|
.clicd-nat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;align-items:end}
|
||||||
|
.clicd-nat-field label{display:block;color:#6b7280;font-size:12px;margin-bottom:4px}
|
||||||
|
.clicd-nat-input,.clicd-nat-select{width:100%;height:34px;border:1px solid #d1d5db;border-radius:4px;padding:6px 8px;box-sizing:border-box}
|
||||||
|
.clicd-nat-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.clicd-nat-btn{height:34px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
|
||||||
|
.clicd-nat-btn[disabled]{opacity:.6;cursor:not-allowed}
|
||||||
|
.clicd-nat-btn-secondary{border-color:#d1d5db;background:#fff;color:#374151}
|
||||||
|
.clicd-nat-btn-danger{border-color:#dc2626;background:#dc2626;color:#fff}
|
||||||
|
.clicd-nat-list{display:flex;flex-direction:column;gap:10px;margin-top:8px}
|
||||||
|
.clicd-nat-item{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px}
|
||||||
|
.clicd-nat-message{border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:6px;padding:10px 12px;margin-bottom:12px;display:none}
|
||||||
|
.clicd-nat-message.error{border-color:#fecaca;background:#fef2f2;color:#b91c1c}
|
||||||
|
.clicd-nat-debug{margin-top:12px;border:1px dashed #d1d5db;border-radius:6px;background:#f9fafb;padding:10px;color:#374151;white-space:pre-wrap;font-size:12px;display:none}
|
||||||
|
.clicd-nat-modal-mask{position:fixed;inset:0;background:rgba(15,23,42,.42);display:none;align-items:center;justify-content:center;z-index:9999;padding:16px}
|
||||||
|
.clicd-nat-modal{width:min(420px,100%);background:#fff;border-radius:6px;border:1px solid #e5e7eb;box-shadow:0 18px 48px rgba(15,23,42,.22);padding:16px}
|
||||||
|
.clicd-nat-modal-title{font-size:16px;font-weight:700;color:#111827;margin-bottom:8px}
|
||||||
|
.clicd-nat-modal-body{font-size:14px;color:#4b5563;line-height:1.6;margin-bottom:14px}
|
||||||
|
.clicd-nat-modal-actions{display:flex;justify-content:flex-end;gap:8px}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="clicd-nat-panel" id="clicd-nat-panel" data-service-id="{$service_id}" data-area-key="{$area_key}">
|
||||||
|
<div class="clicd-nat-message" id="clicd-nat-message"></div>
|
||||||
|
|
||||||
|
<div class="clicd-nat-grid">
|
||||||
|
<div class="clicd-nat-card">
|
||||||
|
<div class="clicd-nat-label">实例名称</div>
|
||||||
|
<div class="clicd-nat-value">{$container_name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-card">
|
||||||
|
<div class="clicd-nat-label">公网地址</div>
|
||||||
|
<div class="clicd-nat-value">{$nat_host}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-card">
|
||||||
|
<div class="clicd-nat-label">SSH 端口</div>
|
||||||
|
<div class="clicd-nat-value">{$ssh_port}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-nat-title">添加端口映射</div>
|
||||||
|
<div class="clicd-nat-form">
|
||||||
|
<div class="clicd-nat-row">
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>公网端口</label>
|
||||||
|
<input class="clicd-nat-input" id="clicd-add-host-port" type="number" min="1" max="65535" placeholder="61320">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>容器端口</label>
|
||||||
|
<input class="clicd-nat-input" id="clicd-add-container-port" type="number" min="1" max="65535" placeholder="8080">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>协议</label>
|
||||||
|
<select class="clicd-nat-select" id="clicd-add-protocol">
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>说明</label>
|
||||||
|
<input class="clicd-nat-input" id="clicd-add-description" type="text" placeholder="HTTP">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-actions">
|
||||||
|
<button class="clicd-nat-btn" type="button" data-clicd-action="add">添加</button>
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="random-port">获取随机端口</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clicd-nat-title">现有端口映射</div>
|
||||||
|
<div id="clicd-nat-list" class="clicd-nat-list">
|
||||||
|
{if condition="empty($port_mappings)"}
|
||||||
|
<div class="clicd-nat-form clicd-nat-muted">暂无端口映射</div>
|
||||||
|
{else/}
|
||||||
|
{foreach name="port_mappings" item="mapping"}
|
||||||
|
<div class="clicd-nat-item" data-index="{$mapping.index}">
|
||||||
|
<div class="clicd-nat-row">
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>索引</label>
|
||||||
|
<div class="clicd-nat-value">{$mapping.index}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>公网访问</label>
|
||||||
|
<div class="clicd-nat-value">{$nat_host}:{$mapping.host_port}</div>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>公网端口</label>
|
||||||
|
<input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="{$mapping.host_port}">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>容器端口</label>
|
||||||
|
<input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="{$mapping.container_port}">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>协议</label>
|
||||||
|
<select class="clicd-nat-select" data-field="protocol">
|
||||||
|
<option value="tcp" {$mapping.tcp_selected}>TCP</option>
|
||||||
|
<option value="udp" {$mapping.udp_selected}>UDP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-field">
|
||||||
|
<label>说明</label>
|
||||||
|
<input class="clicd-nat-input" data-field="description" type="text" value="{$mapping.description}">
|
||||||
|
</div>
|
||||||
|
<div class="clicd-nat-actions">
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">保存</button>
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/foreach}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre class="clicd-nat-debug" id="clicd-nat-debug"></pre>
|
||||||
|
<div class="clicd-nat-modal-mask" id="clicd-nat-delete-modal">
|
||||||
|
<div class="clicd-nat-modal">
|
||||||
|
<div class="clicd-nat-modal-title">确认删除</div>
|
||||||
|
<div class="clicd-nat-modal-body" id="clicd-nat-delete-text">确认删除该端口映射?</div>
|
||||||
|
<div class="clicd-nat-modal-actions">
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" id="clicd-nat-delete-cancel">取消</button>
|
||||||
|
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" id="clicd-nat-delete-confirm">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var panel = document.getElementById('clicd-nat-panel');
|
||||||
|
if (!panel || panel.getAttribute('data-bound') === '1') return;
|
||||||
|
panel.setAttribute('data-bound', '1');
|
||||||
|
|
||||||
|
var message = document.getElementById('clicd-nat-message');
|
||||||
|
var debugBox = document.getElementById('clicd-nat-debug');
|
||||||
|
var list = document.getElementById('clicd-nat-list');
|
||||||
|
var natHost = '{$nat_host}';
|
||||||
|
var deleteModal = document.getElementById('clicd-nat-delete-modal');
|
||||||
|
var deleteText = document.getElementById('clicd-nat-delete-text');
|
||||||
|
var deleteCancel = document.getElementById('clicd-nat-delete-cancel');
|
||||||
|
var deleteConfirm = document.getElementById('clicd-nat-delete-confirm');
|
||||||
|
var pendingDeletePayload = null;
|
||||||
|
|
||||||
|
function showMessage(type, text) {
|
||||||
|
message.className = 'clicd-nat-message' + (type === 'error' ? ' error' : '');
|
||||||
|
message.style.display = 'block';
|
||||||
|
message.textContent = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDebug(data) {
|
||||||
|
debugBox.style.display = 'block';
|
||||||
|
debugBox.textContent = JSON.stringify(data || {}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint() {
|
||||||
|
return "{$MODULE_CUSTOM_API}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(item, name) {
|
||||||
|
return item.querySelector('[data-field="' + name + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
panel.querySelectorAll('button').forEach(function(btn){ btn.disabled = !!busy; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(items) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
list.innerHTML = '<div class="clicd-nat-form clicd-nat-muted">暂无端口映射</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(function(item) {
|
||||||
|
var protocol = (item.protocol || 'tcp').toLowerCase();
|
||||||
|
var desc = escapeHtml(item.description || '');
|
||||||
|
var index = escapeHtml(String(item.index));
|
||||||
|
var hostPort = escapeHtml(String(item.host_port || ''));
|
||||||
|
var containerPort = escapeHtml(String(item.container_port || ''));
|
||||||
|
return '<div class="clicd-nat-item" data-index="' + index + '">' +
|
||||||
|
'<div class="clicd-nat-row">' +
|
||||||
|
'<div class="clicd-nat-field"><label>索引</label><div class="clicd-nat-value">' + index + '</div></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>公网访问</label><div class="clicd-nat-value">' + escapeHtml(natHost) + ':' + hostPort + '</div></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>公网端口</label><input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="' + hostPort + '"></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>容器端口</label><input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="' + containerPort + '"></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>协议</label><select class="clicd-nat-select" data-field="protocol">' +
|
||||||
|
'<option value="tcp"' + (protocol === 'tcp' ? ' selected' : '') + '>TCP</option>' +
|
||||||
|
'<option value="udp"' + (protocol === 'udp' ? ' selected' : '') + '>UDP</option>' +
|
||||||
|
'</select></div>' +
|
||||||
|
'<div class="clicd-nat-field"><label>说明</label><input class="clicd-nat-input" data-field="description" type="text" value="' + desc + '"></div>' +
|
||||||
|
'<div class="clicd-nat-actions"><button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">保存</button>' +
|
||||||
|
'<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">删除</button></div>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(action, payload, silent) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
var funcMap = {
|
||||||
|
'random-port': 'randomPort',
|
||||||
|
'add': 'addNat',
|
||||||
|
'update': 'updateNat',
|
||||||
|
'delete': 'deleteNat',
|
||||||
|
'list': 'natList'
|
||||||
|
};
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('id', panel.getAttribute('data-service-id') || '');
|
||||||
|
body.set('func', funcMap[action] || action);
|
||||||
|
Object.keys(payload || {}).forEach(function(key){ body.set(key, payload[key]); });
|
||||||
|
var res = await fetch(endpoint(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Authorization': 'JWT {$Think.get.jwt}'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body.toString()
|
||||||
|
});
|
||||||
|
var text = await res.text();
|
||||||
|
var data;
|
||||||
|
try { data = JSON.parse(text); } catch (e) { data = {status:'error', msg:'\u975e JSON \u54cd\u5e94: ' + text}; }
|
||||||
|
showDebug((data.data && data.data.debug) || data.debug || data);
|
||||||
|
if (data.status === 200 || data.status === 'success') {
|
||||||
|
if (!silent) {
|
||||||
|
showMessage('success', data.msg || '\u64cd\u4f5c\u6210\u529f');
|
||||||
|
}
|
||||||
|
if (data.data && data.data.port) {
|
||||||
|
document.getElementById('clicd-add-host-port').value = data.data.port;
|
||||||
|
}
|
||||||
|
if (data.data && Array.isArray(data.data.port_mappings)) {
|
||||||
|
renderList(data.data.port_mappings);
|
||||||
|
} else if (action !== 'random-port') {
|
||||||
|
request('list', {}, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showMessage('error', data.msg || '\u64cd\u4f5c\u5931\u8d25');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('error', e.message || '\u8bf7\u6c42\u5931\u8d25');
|
||||||
|
showDebug({error: String(e)});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(payload) {
|
||||||
|
pendingDeletePayload = payload;
|
||||||
|
if (deleteText) {
|
||||||
|
deleteText.textContent = '\u786e\u8ba4\u5220\u9664\u7aef\u53e3\u6620\u5c04 ' + natHost + ':' + (payload.host_port || '-') + ' -> ' + (payload.container_port || '-') + '/' + (payload.protocol || 'tcp') + ' \u5417\uff1f';
|
||||||
|
}
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
pendingDeletePayload = null;
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.addEventListener('click', function(event) {
|
||||||
|
var button = event.target.closest('[data-clicd-action]');
|
||||||
|
if (!button) return;
|
||||||
|
var action = button.getAttribute('data-clicd-action');
|
||||||
|
if (action === 'random-port') {
|
||||||
|
request('random-port', {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'add') {
|
||||||
|
request('add', {
|
||||||
|
host_port: document.getElementById('clicd-add-host-port').value,
|
||||||
|
container_port: document.getElementById('clicd-add-container-port').value,
|
||||||
|
protocol: document.getElementById('clicd-add-protocol').value,
|
||||||
|
description: document.getElementById('clicd-add-description').value
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var item = button.closest('.clicd-nat-item');
|
||||||
|
if (!item) return;
|
||||||
|
var payload = {
|
||||||
|
index: item.getAttribute('data-index'),
|
||||||
|
host_port: field(item, 'host_port') ? field(item, 'host_port').value : '',
|
||||||
|
container_port: field(item, 'container_port') ? field(item, 'container_port').value : '',
|
||||||
|
protocol: field(item, 'protocol') ? field(item, 'protocol').value : 'tcp',
|
||||||
|
description: field(item, 'description') ? field(item, 'description').value : ''
|
||||||
|
};
|
||||||
|
if (action === 'delete') {
|
||||||
|
openDeleteModal(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request(action, payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteCancel) {
|
||||||
|
deleteCancel.addEventListener('click', closeDeleteModal);
|
||||||
|
}
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.addEventListener('click', function(event){
|
||||||
|
if (event.target === deleteModal) closeDeleteModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (deleteConfirm) {
|
||||||
|
deleteConfirm.addEventListener('click', function(){
|
||||||
|
if (!pendingDeletePayload) return;
|
||||||
|
var payload = pendingDeletePayload;
|
||||||
|
closeDeleteModal();
|
||||||
|
request('delete', payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user