feat: 新增镜像管理 + 控制台实例管理功能

- 新增 Harbor 镜像仓库查询、最新 tag 查询、skopeo 推送接口
- 新增 Choerodon DevOps 实例列表、Pod 状态、YAML 配置查询与更新接口
- 修复全局异常过滤器 404 日志刷屏问题
- 前端新增「镜像管理」和「控制台实例」Tab 页

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 18:52:16 +08:00
co-authored by Claude Sonnet 4.6
parent 2f068c6a5f
commit e0231fca8f
12 changed files with 1285 additions and 7 deletions
+149
View File
@@ -0,0 +1,149 @@
# xj-oss-syn 项目说明
## 项目目标
1. **OSS 同步**:将业务系统中存储在本地的文件批量上传到新疆油田 IOSP OSS 平台,并将业务表中的文件路径更新为 OSS 路径。
2. **镜像管理**:通过 Harbor API + skopeo CLI 查询镜像仓库列表、最新 tag,并支持将 tar 包推送到 Harbor。
3. **控制台实例管理**:通过 Choerodon DevOps API 查询实例列表、Pod 状态、YAML 配置,并支持更新配置。
## 技术栈
- 框架:Solon 3.10.0
- ORMMyBatis-Plussolon 插件)
- 数据库:MySQL 8.xHikariCP 连接池)
- HTTP 客户端:Java 内置 `java.net.http.HttpClient`trust-all SSLContext,支持 NEVER/NORMAL 重定向策略)
- JSONFastjson2
- 认证:Sa-Token(单账号 HTTP Basic,凭证来自 `sa-token.http-basic` 配置)
- 模板引擎:FreeMarker`src/main/resources/templates/`
- 构建:Maven + solon-maven-plugin
## 包结构
```
com.yxtech.ossyn
├── App.java # 启动入口
├── config/
│ ├── BizDataSourceConfig.java # 业务库连接公共参数
│ ├── BizFileMappingConfig.java # 业务表映射配置(来自 yml)
│ ├── ConsoleConfig.java # Choerodon 控制台参数
│ ├── HarborConfig.java # Harbor 镜像仓库参数
│ ├── LocalFileConfig.java # 本地文件根路径
│ ├── MybatisPlusConfig.java # MyBatis-Plus 分页插件等
│ ├── OssConfig.java # OSS 连接参数
│ ├── SaTokenConfig.java # Sa-Token 路由白名单
│ └── SkopeoConfig.java # skopeo 可执行文件及镜像目录
├── controller/
│ ├── AuthController.java # 登录/登出页面(/auth/*
│ ├── BizFileMappingController.java # 映射配置 CRUD 接口
│ ├── DevopsController.java # 镜像管理 + 控制台实例接口(/devops/*
│ └── OssSyncController.java # OSS 同步触发接口(/oss-sync/*
├── entity/
│ ├── BizFileMappingEntity.java # 业务表映射配置实体
│ ├── QhOssFileLog.java # 文件转换日志实体
│ └── SyncTask.java # 同步任务实体
├── filter/
│ └── GlobalExceptionFilter.java # 全局异常处理(404 DEBUG / 其他 ERROR
├── mapper/
│ ├── BizFileMappingMapper.java
│ ├── QhOssFileLogMapper.java
│ └── SyncTaskMapper.java
├── service/
│ ├── ConsoleService.java # Choerodon 控制台 APItoken 缓存 + 401 重试)
│ ├── HarborService.java # Harbor API + skopeo pushtrust-all SSL
│ └── OssSyncService.java # OSS 同步核心逻辑
└── util/
├── DynamicDataSourceUtil.java # 动态数据源(按 dbName 创建连接池)
└── OssUtil.java # OSS 上传工具(内存缓存 token)
```
## 数据库
- **数据源1(本项目)**:存储 `QH_OSS_FILE_LOG` 日志表,建表脚本见 `sql/init.sql`
- **数据源2(业务库)**:动态创建,根据 `biz-file-mappings` 配置的 dbName 连接
## 配置说明(app.yml
| 配置项 | 说明 |
|--------|------|
| `solon.dataSources.db1!` | 本项目数据库连接 |
| `server.request-maxBodySize` | 请求体大小上限(当前 1GB,支持大镜像上传) |
| `biz-datasource` | 业务库公共连接参数(host/port/user/pass |
| `biz-file-mappings` | 业务表映射列表(dbName/tableName/idCol/fileCol |
| `local-file.root-path` | 本地文件根路径 |
| `xj-oss.*` | OSS 网关、鉴权、目录等参数 |
| `harbor.*` | Harbor 地址、项目名、账号密码 |
| `skopeo.exe-path` | skopeo.exe 可执行文件路径 |
| `skopeo.images-path` | 镜像 tar 包存放目录 |
| `console.api-base` | Choerodon DevOps API 网关地址 |
| `console.project-id` | 控制台项目 ID |
| `console.env-id` | 控制台环境 ID |
| `console.login-username/password` | 控制台登录账号 |
| `console.public-key` | 控制台 RSA 公钥(用于密码加密) |
| `sa-token.http-basic` | 本系统登录凭证(格式:用户名:密码) |
> 敏感配置(密码、密钥等)可通过 `app-env.yml` 覆盖,该文件不纳入版本管理。
## HTTP 接口
### 认证(AuthController
| 接口 | 说明 |
|------|------|
| GET /auth/login | 显示登录页 |
| POST /auth/doLogin | 处理登录(凭证来自 sa-token.http-basic |
| GET /auth/logout | 退出登录 |
### OSS 同步(OssSyncController
| 接口 | 说明 |
|------|------|
| POST /oss-sync/all | 一键全量同步(扫描+上传+回写) |
| POST /oss-sync/scan | 仅扫描业务表,写入日志 |
| POST /oss-sync/upload | 仅上传待处理文件 |
| POST /oss-sync/write-back | 仅回写业务表 |
### DevOpsDevopsController
| 接口 | 说明 |
|------|------|
| GET /devops/harbor/repositories | 查询 Harbor 镜像仓库列表 |
| GET /devops/harbor/latest-tag?repo= | 查询指定仓库最新 tag |
| POST /devops/harbor/push | 上传 tar 包并推送到 Harbormultipart,自动计算下一个 tag |
| POST /devops/console/instances | 查询所有实例(全量 size=500 |
| POST /devops/console/pods | 查询实例 Pod 列表 |
| GET /devops/console/yaml | 查询实例 YAML 配置 |
| PUT /devops/console/yaml | 更新实例 YAML 配置 |
## OSS 处理状态说明
| 值 | 含义 |
|----|------|
| 0 | 待处理 |
| 1 | 上传成功 |
| 2 | 上传失败 |
| 3 | 文件不存在 |
| 4 | 已回写业务表 |
## 关键设计说明
### Choerodon 控制台登录流程
登录需要两步(均需要 `NEVER` 重定向策略才能拿到 302 响应头):
1. `POST /oauth/choerodon/login` → 收集响应中所有 `Set-Cookie``allValues`),拼成完整 Cookie 字符串
2. `GET /oauth/oauth/authorize` 携带 Cookie → 从 302 响应的 `Location` URL fragment`#` 后)提取 `access_token`
- `authClient``NEVER` 重定向(登录/授权专用)
- `apiClient``NORMAL` 重定向(业务请求专用)
- token 内存缓存,有效期 ~24h;收到 401 自动清除缓存并重试一次
### SSL 证书
内部域名使用自签证书,`HarborService.buildTrustAllClient()` 构造 trust-all `SSLContext`
所有 `HttpClient` 实例均通过该方法创建,跳过证书验证。
### 镜像上传(skopeo
```
skopeo copy --dest-creds user:pass --dest-tls-verify=false \
docker-archive:{tarAbsPath} \
docker://{harborHost}/{project}/{repo}:{tag}
```
- tar 包按日期子目录存放(`imagesPath/yyyyMMdd/filename.tar`
- tag 自动在最新 tag 基础上末位 +1(如 `1.0.10``1.0.11`
### 注意事项
- 扫描阶段幂等,同一业务表+ID+列已存在日志则跳过
- OSS token 使用内存缓存,无需 Redis
- 动态数据源按 dbName 缓存连接池,避免重复创建
- 业务库列名/表名来自内部配置,不存在 SQL 注入风险
- 文件上传暂不支持 JSON 类型(TXX-19,待排查)
## Linear 任务追踪
所有功能任务和 Bug 统一记录到 Linear `tar-update` 项目(团队 `txxz`)。
+155
View File
@@ -3,6 +3,16 @@ package com.yxtech.ossyn;
import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;
import javax.crypto.Cipher;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* 应用程序入口
*/
@@ -11,4 +21,149 @@ public class App {
public static void main(String[] args) {
Solon.start(App.class, args);
}
// private static final String BASE_URL = "http://api.dev.xjyt.petrochina/oauth/choerodon/login"; // 替换为实际域名
// private static final String PUBLIC_KEY = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJL0JkqsUoK6kt3JyogsgqNp9VDGDp+t3ZAGMbVoMPdHNT2nfiIVh9ZMNHF7g2XiAa8O8AQWyh2PjMR0NiUSVQMCAwEAAQ==";
//
// /**
// * RSA 加密密码
// */
// public static String encryptPassword(String password) throws Exception {
// byte[] keyBytes = Base64.getDecoder().decode(PUBLIC_KEY);
// X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
// PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(keySpec);
//
// Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
// cipher.init(Cipher.ENCRYPT_MODE, publicKey);
// byte[] encrypted = cipher.doFinal(password.getBytes("UTF-8"));
// return Base64.getEncoder().encodeToString(encrypted);
// }
//
// /**
// * 登录,返回 Cookie(后续请求需携带)
// */
// public static String login(String username, String password) throws Exception {
// String encryptedPassword = encryptPassword(password);
//
// // 构建 form 表单体
// String formBody = "username=" + java.net.URLEncoder.encode(username, "UTF-8")
// + "&password=" + java.net.URLEncoder.encode(encryptedPassword, "UTF-8");
//
// // 打印等效的 curl 命令(Windows 格式)
// System.out.println("curl \"" + BASE_URL + "\" ^");
// System.out.println(" -H \"Origin: http://api.dev.xjyt.petrochina\" ^");
// System.out.println(" -H \"Content-Type: application/x-www-form-urlencoded\" ^");
// System.out.println(" -H \"User-Agent: Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36\" ^");
// System.out.println(" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9\" ^");
// System.out.println(" -H \"Referer: http://api.dev.xjyt.petrochina/oauth/choerodon/login\" ^");
// System.out.println(" -H \"Accept-Language: zh-CN,zh;q=0.9\" ^");
// System.out.println(" -H \"Cookie: SESSION=M2MwYTZjM2ItNzk4Ni00NTM0LWJlYzYtZDAwMDA5Zjc2ZTE1; route=289a995e776d44c58b544d574adb34a0; _hatt=26a898962c71fb9c70f01348ee9e32bb\" ^");
// System.out.println(" --data-raw \"" + formBody + "\"");
//
// HttpClient client = HttpClient.newBuilder()
// .followRedirects(HttpClient.Redirect.NEVER)
// .build();
//
// HttpRequest request = HttpRequest.newBuilder()
// .uri(URI.create(BASE_URL))
// .header("Origin", "http://api.dev.xjyt.petrochina")
// .header("Content-Type", "application/x-www-form-urlencoded")
// .header("User-Agent", "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36")
// .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
// .header("Referer", "http://api.dev.xjyt.petrochina/oauth/choerodon/login")
// .header("Accept-Language", "zh-CN,zh;q=0.9")
// .header("Cookie", "SESSION=M2MwYTZjM2ItNzk4Ni00NTM0LWJlYzYtZDAwMDA5Zjc2ZTE1; route=289a995e776d44c58b544d574adb34a0; _hatt=26a898962c71fb9c70f01348ee9e32bb")
// .POST(HttpRequest.BodyPublishers.ofString(formBody))
// .build();
//
// HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
//
// System.out.println("状态码: " + response.statusCode());
// System.out.println("响应体: " + response.body());
//
// // 登录成功后服务器返回 302Set-Cookie 中携带新 Session
// String cookie = response.headers().firstValue("Set-Cookie").orElse(null);
// System.out.println("Cookie: " + cookie);
//
// return cookie;
// }
//
// /**
// * OAuth 授权,返回重定向地址(含 access_token
// */
// public static String authorize(String cookie) throws Exception {
// String url = "http://api.dev.xjyt.petrochina/oauth/oauth/authorize" +
// "?response_type=token&client_id=choerodon&state=&redirect_uri=" +
// java.net.URLEncoder.encode("http://dev.xjyt.petrochina/#/devops/resource" +
// "?activeKey=instance&category=AGILE&id=748209321615708160" +
// "&name=%E5%85%A5%E5%BA%B7%E7%AE%A1%E7%90%86%E5%B9%B3%E5%8F%B0" +
// "&organizationId=1&type=project&redirectFlag", "UTF-8");
//
// // 打印等效的 curl 命令(Windows 格式)
// System.out.println("curl -v \"" + url + "\" ^");
// System.out.println(" -H \"User-Agent: Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36\" ^");
// System.out.println(" -H \"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8\" ^");
// System.out.println(" -H \"Referer: http://dev.xjyt.petrochina/\" ^");
// System.out.println(" -H \"Accept-Language: zh-CN,zh;q=0.9\" ^");
// System.out.println(" -H \"Cookie: " + cookie + "\" ^");
// System.out.println(" -L");
//
// HttpClient client = HttpClient.newBuilder()
// .followRedirects(HttpClient.Redirect.NEVER)
// .build();
//
// HttpRequest request = HttpRequest.newBuilder()
// .uri(URI.create(url))
// .header("User-Agent", "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36")
// .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
// .header("Referer", "http://dev.xjyt.petrochina/")
// .header("Accept-Language", "zh-CN,zh;q=0.9")
// .header("Cookie", cookie)
// .GET()
// .build();
//
// HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
//
// System.out.println("authorize 状态码: " + response.statusCode());
// System.out.println("authorize 响应体: " + response.body());
//
// // 授权成功返回 302Location 中含 access_token
// String location = response.headers().firstValue("Location").orElse(null);
// System.out.println("Location: " + location);
//
// return location;
// }
//
// public static void main(String[] args) throws Exception {
// String cookie = login("jy-lianlonggang", "UserPwdLlgA@123!");
// System.out.println("登录成功,Cookie: " + cookie);
//
// if (cookie != null) {
// String location = authorize(cookie);
// System.out.println("授权跳转地址: " + location);
//
// String token = extractToken(location);
// System.out.println("access_token: " + token);
// }
// }
//
// /**
// * 从 Location URL 的 fragment# 后)中提取 access_token
// */
// public static String extractToken(String location) {
// if (location == null) return null;
// int hashIdx = location.indexOf('#');
// if (hashIdx < 0) return null;
// String fragment = location.substring(hashIdx + 1);
// // fragment 格式:/path?key=val&access_token=xxx&...
// int queryIdx = fragment.indexOf('?');
// String query = queryIdx >= 0 ? fragment.substring(queryIdx + 1) : fragment;
// for (String param : query.split("&")) {
// if (param.startsWith("access_token=")) {
// return param.substring("access_token=".length());
// }
// }
// return null;
// }
}
@@ -0,0 +1,32 @@
package com.yxtech.ossyn.config;
import lombok.Data;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
/**
* 控制台(Choerodon DevOps)配置
*/
@Data
@Configuration
public class ConsoleConfig {
@Inject("${console.api-base}")
private String apiBase;
@Inject("${console.project-id}")
private String projectId;
@Inject("${console.env-id}")
private String envId;
@Inject("${console.login-username}")
private String loginUsername;
@Inject("${console.login-password}")
private String loginPassword;
/** RSA 公钥(用于密码加密) */
@Inject("${console.public-key}")
private String publicKey;
}
@@ -0,0 +1,25 @@
package com.yxtech.ossyn.config;
import lombok.Data;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
/**
* Harbor 镜像仓库配置
*/
@Data
@Configuration
public class HarborConfig {
@Inject("${harbor.url}")
private String url;
@Inject("${harbor.project}")
private String project;
@Inject("${harbor.username}")
private String username;
@Inject("${harbor.password}")
private String password;
}
@@ -0,0 +1,21 @@
package com.yxtech.ossyn.config;
import lombok.Data;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
/**
* Skopeo 工具配置
*/
@Data
@Configuration
public class SkopeoConfig {
/** skopeo.exe 可执行文件路径 */
@Inject("${skopeo.exe-path}")
private String exePath;
/** 镜像 tar 包存放目录 */
@Inject("${skopeo.images-path}")
private String imagesPath;
}
@@ -0,0 +1,180 @@
package com.yxtech.ossyn.controller;
import com.alibaba.fastjson2.JSONObject;
import com.yxtech.ossyn.service.ConsoleService;
import com.yxtech.ossyn.service.HarborService;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.*;
import org.noear.solon.core.handle.UploadedFile;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
/**
* 镜像管理 + 控制台实例管理接口
*/
@Slf4j
@Controller
@Mapping("/devops")
public class DevopsController {
@Inject
private HarborService harborService;
@Inject
private ConsoleService consoleService;
@Inject("${skopeo.images-path}")
private String imagesPath;
// ===================== Harbor 镜像 =====================
/** 查询镜像仓库列表 */
@Get
@Mapping("/harbor/repositories")
public Map<String, Object> listRepositories() {
try {
return ok(harborService.listRepositories());
} catch (Exception e) {
log.error("[Devops] listRepositories error", e);
return fail(e.getMessage());
}
}
/** 查询指定镜像最新 tag */
@Get
@Mapping("/harbor/latest-tag")
public Map<String, Object> latestTag(@Param String repo) {
try {
return ok(harborService.latestTag(repo));
} catch (Exception e) {
log.error("[Devops] latestTag error", e);
return fail(e.getMessage());
}
}
/**
* 上传 tar 包并推送到 Harbor
* 参数:filemultipart)、repo(目标仓库名)、tag(可选,不传则自动+1)
*/
@Post
@Mapping("/harbor/push")
public Map<String, Object> pushImage(
@Param String repo,
@Param(required = false) String tag,
UploadedFile file) {
try {
// 保存 tar 到 images 目录
String subDir = new java.text.SimpleDateFormat("yyyyMMdd")
.format(new java.util.Date());
File dir = Paths.get(imagesPath, subDir).toFile();
if (!dir.exists()) dir.mkdirs();
File tarFile = new File(dir, file.getName());
try (FileOutputStream fos = new FileOutputStream(tarFile)) {
file.getContent().transferTo(fos);
}
// 自动计算 tag
if (tag == null || tag.isBlank()) {
JSONObject latest = harborService.latestTag(repo);
tag = nextTag(latest);
}
String result = harborService.pushImage(subDir + "/" + tarFile.getName(), repo, tag);
Map<String, Object> data = new HashMap<>();
data.put("tag", tag);
data.put("output", result);
return ok(data);
} catch (Exception e) {
log.error("[Devops] pushImage error", e);
return fail(e.getMessage());
}
}
/** 在最新 tag 基础上 +1(支持 1.0.10 → 1.0.11 格式) */
private String nextTag(JSONObject latestArtifact) {
if (latestArtifact == null) return "1.0.1";
var tags = latestArtifact.getJSONArray("tags");
if (tags == null || tags.isEmpty()) return "1.0.1";
String name = tags.getJSONObject(0).getString("name");
if (name == null) return "1.0.1";
String[] parts = name.split("\\.");
try {
int last = Integer.parseInt(parts[parts.length - 1]) + 1;
parts[parts.length - 1] = String.valueOf(last);
return String.join(".", parts);
} catch (NumberFormatException e) {
return name + "-1";
}
}
// ===================== 控制台实例 =====================
/** 查询所有实例 */
@Post
@Mapping("/console/instances")
public Map<String, Object> listInstances() {
try {
return ok(consoleService.listInstances());
} catch (Exception e) {
log.error("[Devops] listInstances error", e);
return fail(e.getMessage());
}
}
/** 查询实例的 Pod 列表 */
@Post
@Mapping("/console/pods")
public Map<String, Object> listPods(@Param String instanceId) {
try {
return ok(consoleService.listPods(instanceId));
} catch (Exception e) {
log.error("[Devops] listPods error", e);
return fail(e.getMessage());
}
}
/** 查询实例 YAML 配置 */
@Get
@Mapping("/console/yaml")
public Map<String, Object> getYaml(@Param String instanceId, @Param String versionId) {
try {
return ok(consoleService.getInstanceYaml(instanceId, versionId));
} catch (Exception e) {
log.error("[Devops] getYaml error", e);
return fail(e.getMessage());
}
}
/** 更新实例 YAML 配置 */
@Put
@Mapping("/console/yaml")
public Map<String, Object> updateYaml(@Body JSONObject payload) {
try {
return ok(consoleService.updateInstanceYaml(payload));
} catch (Exception e) {
log.error("[Devops] updateYaml error", e);
return fail(e.getMessage());
}
}
// ===================== 工具方法 =====================
private Map<String, Object> ok(Object data) {
Map<String, Object> r = new HashMap<>(3);
r.put("code", 200);
r.put("msg", "ok");
r.put("data", data);
return r;
}
private Map<String, Object> fail(String msg) {
Map<String, Object> r = new HashMap<>(2);
r.put("code", 500);
r.put("msg", msg);
return r;
}
}
@@ -5,6 +5,7 @@ import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
import org.noear.solon.core.exception.StatusException;
import java.util.HashMap;
import java.util.Map;
@@ -22,11 +23,21 @@ public class GlobalExceptionFilter implements Filter {
try {
chain.doFilter(ctx);
} catch (Throwable e) {
log.error("[GlobalFilter] 请求异常 {} {} params={}",
ctx.method(), ctx.path(), ctx.paramMap(), e);
ctx.status(500);
int status = 500;
// StatusException 直接用其状态码,避免 404 被当成 500 处理
if (e instanceof StatusException se) {
status = se.getCode();
}
// 静态资源 404 不打 ERROR,避免刷屏
if (status == 404) {
log.debug("[GlobalFilter] 404 {} {}", ctx.method(), ctx.path());
} else {
log.error("[GlobalFilter] 请求异常 {} {} params={}",
ctx.method(), ctx.path(), ctx.paramMap(), e);
}
ctx.status(status);
Map<String, Object> result = new HashMap<>(2);
result.put("code", 500);
result.put("code", status);
result.put("msg", e.getMessage());
ctx.render(result);
}
@@ -0,0 +1,247 @@
package com.yxtech.ossyn.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.yxtech.ossyn.config.ConsoleConfig;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import javax.crypto.Cipher;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicLong;
/**
* 控制台(Choerodon DevOps)服务
* token 内存缓存,401 时自动重新登录
*/
@Slf4j
@Component
public class ConsoleService {
@Inject
private ConsoleConfig cfg;
/** 缓存的 access_token */
private volatile String cachedToken;
/** token 过期时间戳(毫秒) */
private final AtomicLong tokenExpireAt = new AtomicLong(0);
/** 登录/授权专用:不跟随重定向,才能拿到 302 的 Set-Cookie 和 Location */
private final HttpClient authClient = HarborService.buildTrustAllClientNoRedirect();
/** 业务请求专用:跟随重定向 */
private final HttpClient apiClient = HarborService.buildTrustAllClient();
// ===================== token 获取 =====================
/** 强制清除缓存 token,下次调用 getToken() 时重新登录 */
public void invalidateToken() {
cachedToken = null;
tokenExpireAt.set(0);
log.info("[Console] token 缓存已清除,下次请求将重新登录");
}
/** 获取有效 token(自动刷新) */
public String getToken() throws Exception {
if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) {
return cachedToken;
}
synchronized (this) {
if (cachedToken != null && System.currentTimeMillis() < tokenExpireAt.get() - 60_000) {
return cachedToken;
}
String cookie = login();
String location = authorize(cookie);
String token = extractToken(location);
if (token == null) throw new RuntimeException("授权成功但未能从 Location 中提取 access_token: " + location);
cachedToken = token;
tokenExpireAt.set(System.currentTimeMillis() + 86399_000L);
log.info("[Console] token 已刷新");
}
return cachedToken;
}
private String login() throws Exception {
String encPwd = encryptPassword(cfg.getLoginPassword(), cfg.getPublicKey());
String formBody = "username=" + java.net.URLEncoder.encode(cfg.getLoginUsername(), "UTF-8")
+ "&password=" + java.net.URLEncoder.encode(encPwd, "UTF-8");
String loginUrl = cfg.getApiBase() + "/oauth/choerodon/login";
log.info("[Console][login] curl \"{}\" ^\n -H \"Origin: {}\" ^\n -H \"Content-Type: application/x-www-form-urlencoded\" ^\n --data-raw \"{}\"",
loginUrl, cfg.getApiBase(), formBody);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(loginUrl))
.header("Origin", cfg.getApiBase())
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", "Mozilla/5.0")
.header("Referer", loginUrl)
.POST(HttpRequest.BodyPublishers.ofString(formBody))
.build();
// 使用 authClientNEVER 重定向),确保能拿到 302 响应中的 Set-Cookie
HttpResponse<String> resp = authClient.send(req, HttpResponse.BodyHandlers.ofString());
log.info("[Console][login] status={}", resp.statusCode());
log.info("[Console][login] headers={}", resp.headers().map());
log.info("[Console][login] body={}", resp.body());
// 收集所有 Set-Cookie,拼成 Cookie 字符串传给 authorize
java.util.List<String> cookies = resp.headers().allValues("Set-Cookie");
if (cookies.isEmpty()) {
throw new RuntimeException("登录失败,未获取到 Set-Cookie,状态码: " + resp.statusCode());
}
String cookie = cookies.stream()
.map(c -> c.split(";")[0]) // 每条只取 name=value 部分
.collect(java.util.stream.Collectors.joining("; "));
log.info("[Console][login] 拼装 Cookie: {}", cookie);
return cookie;
}
private String authorize(String cookie) throws Exception {
String redirectUri = java.net.URLEncoder.encode(
"http://dev.xjyt.petrochina/#/devops/resource?activeKey=instance&category=AGILE"
+ "&id=" + cfg.getProjectId()
+ "&organizationId=1&type=project&redirectFlag", "UTF-8");
String url = cfg.getApiBase() + "/oauth/oauth/authorize"
+ "?response_type=token&client_id=choerodon&state=&redirect_uri=" + redirectUri;
log.info("[Console][authorize] curl -v \"{}\" ^\n -H \"Cookie: {}\"", url, cookie);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", "Mozilla/5.0")
.header("Referer", "http://dev.xjyt.petrochina/")
.header("Cookie", cookie)
.GET().build();
// 使用 authClientNEVER 重定向),确保能拿到 302 的 Location 头
HttpResponse<String> resp = authClient.send(req, HttpResponse.BodyHandlers.ofString());
log.info("[Console][authorize] status={}", resp.statusCode());
log.info("[Console][authorize] headers={}", resp.headers().map());
log.info("[Console][authorize] body={}", resp.body());
String location = resp.headers().firstValue("Location").orElse(null);
log.info("[Console][authorize] Location={}", location);
if (location == null) {
throw new RuntimeException("授权失败,未获取到 Location,状态码: " + resp.statusCode());
}
return location;
}
/** 从 Location URL 的 fragment# 后)中提取 access_token */
private String extractToken(String location) {
int hashIdx = location.indexOf('#');
if (hashIdx < 0) return null;
String fragment = location.substring(hashIdx + 1);
int queryIdx = fragment.indexOf('?');
String query = queryIdx >= 0 ? fragment.substring(queryIdx + 1) : fragment;
for (String param : query.split("&")) {
if (param.startsWith("access_token=")) return param.substring("access_token=".length());
}
return null;
}
private String encryptPassword(String password, String publicKeyBase64) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(keyBytes));
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return Base64.getEncoder().encodeToString(cipher.doFinal(password.getBytes("UTF-8")));
}
// ===================== 通用请求工具 =====================
private HttpRequest.Builder baseRequest(String url) throws Exception {
return HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "bearer " + getToken())
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("H-Tenant-Id", "1")
.header("H-Menu-Id", "1968")
.header("User-Agent", "Mozilla/5.0")
.header("Origin", "http://dev.xjyt.petrochina")
.header("Referer", "http://dev.xjyt.petrochina/");
}
/**
* 发送请求,401 时清除 token 缓存并重试一次
*/
private HttpResponse<String> sendWithRetry(HttpRequest.Builder reqBuilder, String body, String method) throws Exception {
HttpRequest req = buildReq(reqBuilder, body, method);
HttpResponse<String> resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 401) {
log.warn("[Console] 收到 401,清除 token 缓存后重试");
invalidateToken();
// 重新构建请求(带新 token
String url = req.uri().toString();
HttpRequest.Builder newBuilder = baseRequest(url);
req = buildReq(newBuilder, body, method);
resp = apiClient.send(req, HttpResponse.BodyHandlers.ofString());
}
return resp;
}
private HttpRequest buildReq(HttpRequest.Builder builder, String body, String method) {
return switch (method) {
case "POST" -> builder.POST(body != null
? HttpRequest.BodyPublishers.ofString(body)
: HttpRequest.BodyPublishers.noBody()).build();
case "PUT" -> builder.PUT(body != null
? HttpRequest.BodyPublishers.ofString(body)
: HttpRequest.BodyPublishers.noBody()).build();
default -> builder.GET().build();
};
}
// ===================== 实例列表 =====================
/** 查询所有实例(全量,size=500) */
public JSONObject listInstances() throws Exception {
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
+ "/app_service_instances/info/page_by_options?page=0&size=500&env_id=" + cfg.getEnvId();
String body = "{\"params\":[],\"searchParam\":{}}";
HttpResponse<String> resp = sendWithRetry(baseRequest(url), body, "POST");
log.info("[Console][listInstances] status={}", resp.statusCode());
return JSON.parseObject(resp.body());
}
// ===================== Pod 列表 =====================
/** 查询指定实例的 Pod 列表 */
public JSONObject listPods(String instanceId) throws Exception {
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
+ "/pods/page_by_options?page=0&size=50&env_id=" + cfg.getEnvId()
+ "&instance_id=" + instanceId;
String body = "{\"params\":[],\"searchParam\":{}}";
HttpResponse<String> resp = sendWithRetry(baseRequest(url), body, "POST");
return JSON.parseObject(resp.body());
}
// ===================== YAML 配置 =====================
/** 查询实例的 YAML 配置 */
public JSONObject getInstanceYaml(String instanceId, String versionId) throws Exception {
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
+ "/app_service_instances/" + instanceId
+ "/appServiceVersion/" + versionId + "/upgrade_value";
HttpResponse<String> resp = sendWithRetry(baseRequest(url), null, "GET");
return JSON.parseObject(resp.body());
}
/** 更新实例的 YAML 配置 */
public JSONObject updateInstanceYaml(JSONObject payload) throws Exception {
String url = cfg.getApiBase() + "/devops/v1/projects/" + cfg.getProjectId()
+ "/app_service_instances";
HttpResponse<String> resp = sendWithRetry(baseRequest(url), payload.toJSONString(), "PUT");
return JSON.parseObject(resp.body());
}
}
@@ -0,0 +1,144 @@
package com.yxtech.ossyn.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.yxtech.ossyn.config.HarborConfig;
import com.yxtech.ossyn.config.SkopeoConfig;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.cert.X509Certificate;
import java.util.Base64;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
/**
* Harbor 镜像仓库服务
*/
@Slf4j
@Component
public class HarborService {
@Inject
private HarborConfig harborConfig;
@Inject
private SkopeoConfig skopeoConfig;
private final HttpClient httpClient = buildTrustAllClient();
/** 构建忽略 SSL 证书验证的 HttpClient(内部域名自签证书) */
public static HttpClient buildTrustAllClient() {
return buildTrustAllClient(HttpClient.Redirect.NORMAL);
}
/** 构建忽略 SSL 证书验证、不跟随重定向的 HttpClient(用于登录/授权,需拿 302 头信息) */
public static HttpClient buildTrustAllClientNoRedirect() {
return buildTrustAllClient(HttpClient.Redirect.NEVER);
}
private static HttpClient buildTrustAllClient(HttpClient.Redirect redirect) {
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}}, null);
return HttpClient.newBuilder()
.followRedirects(redirect)
.sslContext(sslContext)
.build();
} catch (Exception e) {
throw new RuntimeException("初始化 TrustAll SSLContext 失败", e);
}
}
/** 构建 Basic Auth 头 */
private String basicAuth() {
String cred = harborConfig.getUsername() + ":" + harborConfig.getPassword();
return "Basic " + Base64.getEncoder().encodeToString(cred.getBytes());
}
/**
* 查询项目下所有镜像仓库列表
*/
public JSONArray listRepositories() throws Exception {
String url = harborConfig.getUrl() + "/api/v2.0/projects/"
+ harborConfig.getProject() + "/repositories?page=1&page_size=100";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", basicAuth())
.header("Accept", "application/json")
.GET().build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
log.info("[Harbor] listRepositories status={}", resp.statusCode());
return JSON.parseArray(resp.body());
}
/**
* 查询指定镜像仓库的最新 tag
*
* @param repoName 仓库名(不含项目前缀,如 health-bank-admin
*/
public JSONObject latestTag(String repoName) throws Exception {
String url = harborConfig.getUrl() + "/api/v2.0/projects/"
+ harborConfig.getProject() + "/repositories/"
+ repoName + "/artifacts?page_size=1&sort=-push_time&with_tag=true";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", basicAuth())
.header("Accept", "application/json")
.GET().build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
JSONArray arr = JSON.parseArray(resp.body());
if (arr == null || arr.isEmpty()) return null;
return arr.getJSONObject(0);
}
/**
* 使用 skopeo 将 tar 包推送到 Harbor
*
* @param tarRelPath tar 包相对于 images-path 的路径(如 20260417/xxx.tar
* @param repoName 目标仓库名(不含项目前缀)
* @param tag 目标 tag
*/
public String pushImage(String tarRelPath, String repoName, String tag) throws Exception {
String tarAbsPath = skopeoConfig.getImagesPath().replace("\\", "/") + "/" + tarRelPath;
String dest = harborConfig.getUrl().replace("https://", "")
.replace("http://", "");
dest = harborConfig.getUrl().startsWith("https") ? dest : dest;
String destImage = "docker://" + dest.replace("https://", "").replace("http://", "")
+ "/" + harborConfig.getProject() + "/" + repoName + ":" + tag;
// 修正:直接拼完整 harbor host
String harborHost = harborConfig.getUrl().replaceFirst("https?://", "");
destImage = "docker://" + harborHost + "/" + harborConfig.getProject() + "/" + repoName + ":" + tag;
String[] cmd = {
skopeoConfig.getExePath(),
"copy",
"--dest-creds", harborConfig.getUsername() + ":" + harborConfig.getPassword(),
"--dest-tls-verify=false",
"docker-archive:" + tarAbsPath,
destImage
};
log.info("[Harbor] skopeo push: {} -> {}", tarAbsPath, destImage);
Process process = new ProcessBuilder(cmd)
.redirectErrorStream(true)
.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
log.info("[Harbor] skopeo exit={} output={}", exitCode, output);
if (exitCode != 0) {
throw new RuntimeException("skopeo 推送失败: " + output);
}
return output;
}
}
+28 -1
View File
@@ -1,4 +1,7 @@
server.port: 8080
server.request-maxBodySize: 1073741824
solon.config.load:
- "app-env.yml" #可以是环境相关的
solon.app:
name: 'xj-oss-syn'
@@ -68,7 +71,31 @@ xj-oss:
file-type-white-list: dat,svg,dwg,pr,pptm,ai,bak,pptx,lic,db,caj,wps,xmind,eps,exe,doc,docx,xls,xlsx,ppt,pps,pdf,xml,webp,MOV,MP4,HEVC,wmv,rmvb,rm,odt,3gp,swf,gz,tgz,log,bz,bz2,tbz,zip,rar,tar,7z,mp3,mid,ogg,mpga,mp4a,wav,wma,avi,dv,mp4,bmp,xlsm,dps,bak,cdr,et,kdh,nh,,nh,mpeg,mpg,mov,wm,flv,mkv,bmp,jpg,jpeg,gif,png,tif,tiff,tga,psd,txt,php,html,htm,js,css,rtf,rtfd,py,java,rb,sh,pl,sql,xml,gdb,gdbx
# =====================================
# Sa-Token 鉴权配置
# Harbor 镜像仓库配置
# =====================================
harbor:
url: https://harbor.dev.xjyt.petrochina
project: operation-jkglpt
username: jkglpt
password: Jkglpt@123
# =====================================
# Skopeo 配置
# =====================================
skopeo:
exe-path: "E:/skopeo/skopeo.exe"
images-path: "E:/skopeo/images"
# =====================================
# 控制台(Choerodon DevOps)配置
# =====================================
console:
api-base: http://api.dev.xjyt.petrochina
project-id: "748209321615708160"
env-id: "766633041995898880"
login-username: jy-lianlonggang
login-password: "UserPwdLlgA@123!"
public-key: "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJL0JkqsUoK6kt3JyogsgqNp9VDGDp+t3ZAGMbVoMPdHNT2nfiIVh9ZMNHF7g2XiAa8O8AQWyh2PjMR0NiUSVQMCAwEAAQ=="
# =====================================
sa-token:
# token 名称(同时也是 cookie 名称)
+288 -1
View File
@@ -115,6 +115,8 @@
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('sync')">同步管理</button>
<button class="tab-btn" onclick="switchTab('mapping')">映射配置</button>
<button class="tab-btn" onclick="switchTab('harbor')">镜像管理</button>
<button class="tab-btn" onclick="switchTab('console')">控制台实例</button>
</div>
<!-- ==================== 同步管理 Tab ==================== -->
@@ -241,6 +243,107 @@
</div>
</div><!-- end tab-mapping -->
<!-- ==================== 镜像管理 Tab ==================== -->
<div id="tab-harbor" class="tab-panel">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div class="card-title" style="margin-bottom:0">Harbor 镜像仓库</div>
<div style="display:flex;gap:8px">
<button class="btn btn-default" onclick="loadHarbor()">刷新</button>
<button class="btn btn-primary" onclick="openPushModal()">上传镜像</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>仓库名</th><th>镜像数</th><th>最新 Tag</th><th>最后更新</th></tr>
</thead>
<tbody id="harborTableBody">
<tr><td colspan="4" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div><!-- end tab-harbor -->
<!-- ==================== 控制台实例 Tab ==================== -->
<div id="tab-console" class="tab-panel">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div class="card-title" style="margin-bottom:0">控制台实例</div>
<button class="btn btn-default" onclick="loadInstances()">刷新</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>服务名</th><th>Code</th><th>状态</th><th>版本</th><th>最后更新</th><th>操作</th></tr>
</thead>
<tbody id="instanceTableBody">
<tr><td colspan="6" style="text-align:center;color:#aaa;padding:30px">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div><!-- end tab-console -->
</div>
<!-- 上传镜像弹窗 -->
<div class="modal-mask" id="pushModal">
<div class="modal" style="width:440px">
<div class="modal-title">上传镜像到 Harbor</div>
<div class="form-item">
<label>目标仓库 <span style="color:red">*</span></label>
<input id="push-repo" placeholder="如 health-bank-admin">
</div>
<div class="form-item">
<label>Tag(留空自动 +1</label>
<input id="push-tag" placeholder="如 1.0.11,不填则自动计算">
</div>
<div class="form-item">
<label>镜像 tar 包 <span style="color:red">*</span></label>
<input type="file" id="push-file" accept=".tar">
</div>
<div id="push-result" style="font-size:12px;color:#555;margin-top:8px;white-space:pre-wrap;max-height:120px;overflow:auto"></div>
<div class="modal-footer">
<button class="btn btn-default" onclick="document.getElementById('pushModal').classList.remove('show')">取消</button>
<button class="btn btn-primary" id="pushBtn" onclick="doPushImage()">开始上传</button>
</div>
</div>
</div>
<!-- YAML 配置弹窗 -->
<div class="modal-mask" id="yamlModal">
<div class="modal" style="width:700px;max-width:96vw">
<div class="modal-title">实例 YAML 配置</div>
<input type="hidden" id="yaml-instanceId">
<input type="hidden" id="yaml-versionId">
<input type="hidden" id="yaml-envId">
<input type="hidden" id="yaml-appServiceId">
<input type="hidden" id="yaml-appServiceVersionId">
<input type="hidden" id="yaml-objectVersionNumber">
<textarea id="yaml-content" style="width:100%;height:360px;font-family:monospace;font-size:12px;padding:10px;border:1px solid #d9d9d9;border-radius:6px;resize:vertical;outline:none"></textarea>
<div class="modal-footer">
<button class="btn btn-default" onclick="document.getElementById('yamlModal').classList.remove('show')">取消</button>
<button class="btn btn-primary" onclick="doUpdateYaml()">保存更新</button>
</div>
</div>
</div>
<!-- Pod 列表弹窗 -->
<div class="modal-mask" id="podModal">
<div class="modal" style="width:700px;max-width:96vw">
<div class="modal-title" id="podModalTitle">Pod 列表</div>
<div class="table-wrap">
<table>
<thead><tr><th>Pod 名称</th><th>IP</th><th>状态</th><th>Ready</th><th>创建时间</th></tr></thead>
<tbody id="podTableBody"></tbody>
</table>
</div>
<div class="modal-footer">
<button class="btn btn-default" onclick="document.getElementById('podModal').classList.remove('show')">关闭</button>
</div>
</div>
</div>
<!-- 清理日志弹窗 -->
@@ -519,11 +622,13 @@
// ===================== Tab 切换 =====================
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach((b, i) => {
b.classList.toggle('active', ['sync','mapping'][i] === name);
b.classList.toggle('active', ['sync','mapping','harbor','console'][i] === name);
});
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
if (name === 'mapping') loadMappings();
if (name === 'harbor') loadHarbor();
if (name === 'console') loadInstances();
}
// ===================== 映射配置 =====================
@@ -617,6 +722,188 @@
} catch(e) {}
}
// ===================== Harbor 镜像 =====================
async function loadHarbor() {
try {
const res = await fetch('/devops/harbor/repositories');
const json = await res.json();
if (json.code !== 200) return;
const tbody = document.getElementById('harborTableBody');
const list = json.data || [];
if (!list.length) { tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#aaa;padding:30px">暂无数据</td></tr>'; return; }
tbody.innerHTML = list.map(r => {
const shortName = (r.name || '').replace(/^[^/]+\//, '');
return `<tr>
<td>${shortName}</td>
<td>${r.artifact_count ?? '-'}</td>
<td><span id="tag-${shortName}" style="color:#aaa">加载中...</span></td>
<td style="color:#aaa;font-size:12px">${(r.update_time||'').replace('T',' ').substring(0,19)}</td>
</tr>`;
}).join('');
// 渲染完列表后异步逐条加载最新 tag
list.forEach(r => {
const shortName = (r.name || '').replace(/^[^/]+\//, '');
loadLatestTag(shortName);
});
} catch(e) {}
}
async function loadLatestTag(repo) {
try {
const res = await fetch('/devops/harbor/latest-tag?repo=' + encodeURIComponent(repo));
const json = await res.json();
const el = document.getElementById('tag-' + repo);
if (!el) return;
if (json.code === 200 && json.data) {
const tags = json.data.tags;
el.textContent = tags && tags.length ? tags[0].name : '-';
el.style.color = '#1677ff';
}
} catch(e) {}
}
function openPushModal() {
document.getElementById('push-repo').value = '';
document.getElementById('push-tag').value = '';
document.getElementById('push-file').value = '';
document.getElementById('push-result').textContent = '';
document.getElementById('pushModal').classList.add('show');
}
async function doPushImage() {
const repo = document.getElementById('push-repo').value.trim();
const tag = document.getElementById('push-tag').value.trim();
const fileInput = document.getElementById('push-file');
if (!repo) { alert('请填写目标仓库名'); return; }
if (!fileInput.files.length) { alert('请选择 tar 包文件'); return; }
const btn = document.getElementById('pushBtn');
btn.disabled = true;
btn.textContent = '上传中...';
document.getElementById('push-result').textContent = '正在上传,请稍候...';
try {
const fd = new FormData();
fd.append('repo', repo);
if (tag) fd.append('tag', tag);
fd.append('file', fileInput.files[0]);
const res = await fetch('/devops/harbor/push', { method: 'POST', body: fd });
const json = await res.json();
if (json.code === 200) {
document.getElementById('push-result').textContent = '✅ 推送成功!Tag: ' + json.data.tag + '\n' + (json.data.output || '');
loadHarbor();
} else {
document.getElementById('push-result').textContent = '❌ 失败:' + json.msg;
}
} catch(e) {
document.getElementById('push-result').textContent = '❌ 请求失败:' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '开始上传';
}
}
// ===================== 控制台实例 =====================
async function loadInstances() {
try {
const res = await fetch('/devops/console/instances', { method: 'POST' });
const json = await res.json();
if (json.code !== 200) return;
const list = (json.data && json.data.content) || [];
const tbody = document.getElementById('instanceTableBody');
if (!list.length) { tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:30px">暂无数据</td></tr>'; return; }
tbody.innerHTML = list.map(inst => {
const statusColor = inst.status === 'running' ? '#52c41a' : inst.status === 'failed' ? '#f5222d' : '#fa8c16';
return `<tr>
<td>${inst.appServiceName || '-'}</td>
<td style="font-size:12px;color:#888">${inst.code || '-'}</td>
<td><span style="color:${statusColor};font-weight:500">${inst.status || '-'}</span></td>
<td style="font-size:12px">${inst.commandVersion || inst.versionName || '-'}</td>
<td style="color:#aaa;font-size:12px">${(inst.lastUpdateDate||'').substring(0,16)}</td>
<td>
<button class="btn btn-default" style="padding:3px 10px;font-size:12px" onclick="openYamlModal('${inst.id}','${inst.appServiceVersionId||inst.commandVersionId||''}','${inst.appServiceId||''}')">查看配置</button>
<button class="btn btn-default" style="padding:3px 10px;font-size:12px;margin-left:4px" onclick="openPodModal('${inst.id}','${inst.appServiceName||inst.code}')">Pod</button>
</td>
</tr>`;
}).join('');
} catch(e) {}
}
async function openYamlModal(instanceId, versionId, appServiceId) {
document.getElementById('yaml-instanceId').value = instanceId;
document.getElementById('yaml-versionId').value = versionId;
document.getElementById('yaml-appServiceId').value = appServiceId;
document.getElementById('yaml-content').value = '加载中...';
document.getElementById('yamlModal').classList.add('show');
try {
const res = await fetch('/devops/console/yaml?instanceId=' + instanceId + '&versionId=' + versionId);
const json = await res.json();
if (json.code === 200 && json.data) {
document.getElementById('yaml-content').value = json.data.yaml || '';
document.getElementById('yaml-envId').value = json.data.envId || '';
document.getElementById('yaml-appServiceVersionId').value = json.data.id || versionId;
document.getElementById('yaml-objectVersionNumber').value = json.data.objectVersionNumber || 1;
} else {
document.getElementById('yaml-content').value = '加载失败:' + (json.msg || '');
}
} catch(e) { document.getElementById('yaml-content').value = '加载失败:' + e.message; }
}
async function doUpdateYaml() {
const payload = {
values: document.getElementById('yaml-content').value,
instanceId: document.getElementById('yaml-instanceId').value,
type: 'update',
environmentId: document.getElementById('yaml-envId').value,
appServiceId: document.getElementById('yaml-appServiceId').value,
appServiceVersionId: document.getElementById('yaml-appServiceVersionId').value,
};
try {
const res = await fetch('/devops/console/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if (json.code === 200) {
alert('更新成功!');
document.getElementById('yamlModal').classList.remove('show');
loadInstances();
} else {
alert('更新失败:' + json.msg);
}
} catch(e) { alert('请求失败:' + e.message); }
}
async function openPodModal(instanceId, name) {
document.getElementById('podModalTitle').textContent = 'Pod 列表 - ' + name;
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:#aaa;padding:20px">加载中...</td></tr>';
document.getElementById('podModal').classList.add('show');
try {
const res = await fetch('/devops/console/pods', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'instanceId=' + encodeURIComponent(instanceId)
});
const json = await res.json();
const list = (json.data && json.data.content) || [];
if (!list.length) {
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:#aaa;padding:20px">暂无 Pod</td></tr>';
return;
}
document.getElementById('podTableBody').innerHTML = list.map(p => {
const sc = p.status === 'Running' ? '#52c41a' : p.status === 'Pending' ? '#fa8c16' : '#f5222d';
return `<tr>
<td style="font-size:12px">${p.name||'-'}</td>
<td>${p.ip||'-'}</td>
<td><span style="color:${sc};font-weight:500">${p.status||'-'}</span></td>
<td>${p.ready ? '✅' : '❌'}</td>
<td style="color:#aaa;font-size:12px">${(p.creationDate||'').substring(0,16)}</td>
</tr>`;
}).join('');
} catch(e) {
document.getElementById('podTableBody').innerHTML = '<tr><td colspan="5" style="color:#f5222d;padding:20px">加载失败:' + e.message + '</td></tr>';
}
}
// ===================== 初始化 =====================
loadLogs(1);
loadTaskHistory();
+1 -1
View File
@@ -1,6 +1,6 @@
package features;
import com.yxtech.watch.App;
import com.yxtech.ossyn.App;
import org.junit.jupiter.api.Test;