Merge branch 'master' of git@gitee.com:dromara/Jpom.git

This commit is contained in:
bwcx_jzy 2023-02-16 18:02:55 +08:00
commit 6dfa94b3fa
18 changed files with 220 additions and 76 deletions

View File

@ -1,5 +1,18 @@
# 🚀 版本日志
## 2.10.17 (2023-02-16)
1. 【server】新增 构建配置新增严格执行命令模式判断命令执行状态码是否为0 (感谢@阿克苏市姑墨信息科技有限公司) [Gitee pr 169](https://gitee.com/dromara/Jpom/pulls/169)
2. 【server】新增 节点分发新增 webhook 配置属性(感谢@酱总)
### 🐞 解决BUG、优化功能
1. 【server】修复 构建产物配置单属性时,二次匹配不能匹配到文件问题(感谢@伤感的风铃草🌿)
2. 【server】优化 构建历史回滚输出相关操作日志(感谢@酱总)
3. 【server】修复 windows 容器构建无法上传文件到容器问题
------
## 2.10.16 (2023-02-14)
### 🐣 新增功能
@ -12,7 +25,7 @@
1. 【all】优化 解压工具支持多种编码格式GBK、UTF8感谢@Again... .
2. 【server】优化 在线构建新增配置文件环境变量测试(`BUILD_CONFIG_BRANCH_NAME`(感谢@阿克苏市姑墨信息科技有限公司)
3. 【server】修复 节点方法回滚 NPE (感谢@酱总)
3. 【server】修复 节点分发回滚 NPE (感谢@酱总)
4. 【server】优化 构建弹窗部分下拉支持手动刷新数据(感谢@张飞鸿)
------

View File

@ -4,7 +4,7 @@
1. **构建流水线**
2. **资产管理**
3. tomcat 实践案例
3. ~~tomcat 实践案例~~
4. scp 发布实践案例
5. netty-agent
6. 凭证管理
@ -19,8 +19,6 @@
15. **用户体系支持接入第三方系统**
16. 监控通知模块优化支持更多(飞书)
构建参数
### DONE
1. ~~**分片上传文件**~~

View File

@ -743,7 +743,12 @@ public class BuildExecuteService {
map.put("resultFileOut", FileUtil.getAbsolutePath(toFile));
IPlugin plugin = PluginFactory.getPlugin(DockerInfoService.DOCKER_PLUGIN_NAME);
try {
plugin.execute("build", map);
Object execute = plugin.execute("build", map);
int resultCode = Convert.toInt(execute, -100);
// 严格模式
if (buildExtraModule.strictlyEnforce()) {
return resultCode == 0;
}
} catch (Exception e) {
logRecorder.error("构建调用容器异常", e);
return false;
@ -782,6 +787,10 @@ public class BuildExecuteService {
}
});
logRecorder.system("执行脚本的退出码是:{}", waitFor);
// 判断是否为严格执行
if (buildExtraModule.strictlyEnforce()) {
return waitFor == 0;
}
} catch (Exception e) {
logRecorder.error("执行异常", e);
return false;
@ -1067,6 +1076,11 @@ public class BuildExecuteService {
}
});
logRecorder.system("执行 {} 类型脚本的退出码是:{}", type, waitFor);
// 判断是否为严格执行
if (buildExtraModule.strictlyEnforce() && waitFor != 0) {
logRecorder.systemError("严格执行模式,事件脚本返回状态码异常");
return false;
}
return !StrUtil.startWithIgnoreCase(lastMsg[0], "interrupt " + type);
} finally {
try {

View File

@ -162,6 +162,15 @@ public class BuildExtraModule extends BaseModel {
*/
private Boolean projectUploadCloseFirst;
/**
* 是否为严格执行脚本严格执行脚本执行结果返回状态码必须是 0
*/
private Boolean strictlyEnforce;
public boolean strictlyEnforce() {
return strictlyEnforce != null && strictlyEnforce;
}
public String getResultDirFile() {
if (resultDirFile == null) {
return null;

View File

@ -24,6 +24,7 @@ package io.jpom.build;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
@ -112,9 +113,6 @@ public class ReleaseManage {
File logFile = BuildUtil.getLogFile(buildExtraModule.getId(), buildNumberId);
this.logRecorder = LogRecorder.builder().file(logFile).build();
}
this.resultFile = buildExtraModule.resultDirFile(this.buildNumberId);
buildEnv.put("BUILD_RESULT_FILE", FileUtil.getAbsolutePath(this.resultFile));
this.buildExtConfig = SpringUtil.getBean(BuildExtConfig.class);
}
// /**
@ -160,7 +158,11 @@ public class ReleaseManage {
* 不修改为发布中状态
*/
public boolean start() throws Exception {
init();
this.init();
this.resultFile = buildExtraModule.resultDirFile(this.buildNumberId);
this.buildEnv.put("BUILD_RESULT_FILE", FileUtil.getAbsolutePath(this.resultFile));
this.buildExtConfig = SpringUtil.getBean(BuildExtConfig.class);
//
this.updateStatus(BuildStatus.PubIng);
logRecorder.system("开始执行发布,需要发布的文件大小:{}", FileUtil.readableFileSize(FileUtil.size(this.resultFile)));
if (FileUtil.isEmpty(this.resultFile)) {
@ -178,7 +180,7 @@ public class ReleaseManage {
} else if (releaseMethod == BuildReleaseMethod.Ssh.getCode()) {
this.doSsh();
} else if (releaseMethod == BuildReleaseMethod.LocalCommand.getCode()) {
this.localCommand();
return this.localCommand();
} else if (releaseMethod == BuildReleaseMethod.DockerImage.getCode()) {
this.doDockerImage();
} else if (releaseMethod == BuildReleaseMethod.No.getCode()) {
@ -352,12 +354,12 @@ public class ReleaseManage {
/**
* 本地命令执行
*/
private void localCommand() {
private boolean localCommand() {
// 执行命令
String releaseCommand = this.buildExtraModule.getReleaseCommand();
if (StrUtil.isEmpty(releaseCommand)) {
logRecorder.systemError("没有需要执行的命令");
return;
return true;
}
logRecorder.system("{} start exec", DateUtil.now());
@ -375,6 +377,11 @@ public class ReleaseManage {
}
});
logRecorder.system("执行发布脚本的退出码是:{}", waitFor);
// 判断是否为严格执行
if (buildExtraModule.strictlyEnforce()) {
return waitFor == 0;
}
return true;
}
/**
@ -600,10 +607,11 @@ public class ReleaseManage {
public void rollback() {
try {
BaseServerController.resetInfo(userModel);
// 重新标记为发布中
this.updateStatus(BuildStatus.PubIng);
this.init();
logRecorder.system("开始回滚:{}", DateTime.now());
//
boolean start = this.start();
logRecorder.system("执行回滚结束:{}", start);
if (start) {
this.updateStatus(BuildStatus.PubSuccess);
} else {

View File

@ -61,6 +61,10 @@ public class ResultDirFileAction {
private String antSubMatch;
public String antSubMatch() {
if (StrUtil.isEmpty(this.antSubMatch)) {
// 兼容默认数据未配置
return StrUtil.EMPTY;
}
String normalize = FileUtil.normalize(this.antSubMatch);
//需要包裹成目录结构
return StrUtil.wrapIfMissing(normalize, StrUtil.SLASH, StrUtil.SLASH);

View File

@ -24,6 +24,8 @@ package io.jpom.controller.outgiving;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Opt;
import cn.hutool.core.lang.RegexPool;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.StrUtil;
import cn.hutool.db.Entity;
@ -116,7 +118,7 @@ public class OutGivingController extends BaseServerController {
@RequestMapping(value = "save", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
@Feature(method = MethodFeature.EDIT)
public JsonMessage<Object> save(String type, @ValidatorItem String id, HttpServletRequest request) throws IOException {
public JsonMessage<String> save(String type, @ValidatorItem String id, HttpServletRequest request) throws IOException {
if ("add".equalsIgnoreCase(type)) {
//
String checkId = StrUtil.replace(id, StrUtil.DASHED, StrUtil.UNDERLINE);
@ -129,7 +131,7 @@ public class OutGivingController extends BaseServerController {
}
}
private JsonMessage<Object> addOutGiving(String id, HttpServletRequest request) {
private JsonMessage<String> addOutGiving(String id, HttpServletRequest request) {
OutGivingModel outGivingModel = outGivingServer.getByKey(id);
Assert.isNull(outGivingModel, "分发id已经存在啦,分发id需要全局唯一");
//
@ -141,7 +143,7 @@ public class OutGivingController extends BaseServerController {
return JsonMessage.success("添加成功");
}
private JsonMessage<Object> updateGiving(String id, HttpServletRequest request) {
private JsonMessage<String> updateGiving(String id, HttpServletRequest request) {
OutGivingModel outGivingModel = outGivingServer.getByKey(id, request);
Assert.notNull(outGivingModel, "没有找到对应的分发id");
doData(outGivingModel, request);
@ -210,6 +212,15 @@ public class OutGivingController extends BaseServerController {
String secondaryDirectory = getParameter("secondaryDirectory");
outGivingModel.setSecondaryDirectory(secondaryDirectory);
outGivingModel.setUploadCloseFirst(Convert.toBool(getParameter("uploadCloseFirst"), false));
//
String webhook = getParameter("webhook");
webhook = Opt.ofBlankAble(webhook)
.map(s -> {
Validator.validateMatchRegex(RegexPool.URL_HTTP, s, "WebHooks 地址不合法");
return s;
})
.orElse(StrUtil.EMPTY);
outGivingModel.setWebhook(webhook);
}
/**

View File

@ -24,6 +24,8 @@ package io.jpom.controller.outgiving;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Opt;
import cn.hutool.core.lang.RegexPool;
import cn.hutool.core.lang.Tuple;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.StrUtil;
@ -404,6 +406,15 @@ public class OutGivingProjectEditController extends BaseServerController {
String secondaryDirectory = getParameter("secondaryDirectory");
outGivingModel.setSecondaryDirectory(secondaryDirectory);
outGivingModel.setUploadCloseFirst(Convert.toBool(getParameter("uploadCloseFirst"), false));
//
String webhook = getParameter("webhook");
webhook = Opt.ofBlankAble(webhook)
.map(s -> {
Validator.validateMatchRegex(RegexPool.URL_HTTP, s, "WebHooks 地址不合法");
return s;
})
.orElse(StrUtil.EMPTY);
outGivingModel.setWebhook(webhook);
return tuples;
}

View File

@ -102,6 +102,11 @@ public class OutGivingModel extends BaseGroupModel {
*/
private String statusMsg;
/**
* 构建发布状态通知
*/
private String webhook;
public boolean clearOld() {
return clearOld != null && clearOld;
}

View File

@ -46,6 +46,8 @@ import io.jpom.model.log.OutGivingLog;
import io.jpom.model.outgiving.OutGivingModel;
import io.jpom.model.outgiving.OutGivingNodeProject;
import io.jpom.model.user.UserModel;
import io.jpom.plugin.IPlugin;
import io.jpom.plugin.PluginFactory;
import io.jpom.service.outgiving.DbOutGivingLogService;
import io.jpom.service.outgiving.OutGivingServer;
import io.jpom.util.StrictSyncFinisher;
@ -116,13 +118,8 @@ public class OutGivingRun {
dbOutGivingLogService.update(outGivingLog);
}
if (!map1.isEmpty()) {
//
OutGivingServer outGivingServer = SpringUtil.getBean(OutGivingServer.class);
// 更新分发数据
OutGivingModel outGivingModel1 = new OutGivingModel();
outGivingModel1.setId(id);
outGivingModel1.setStatus(OutGivingModel.Status.CANCEL.getCode());
outGivingServer.update(outGivingModel1);
updateStatus(id, OutGivingModel.Status.CANCEL, null);
}
});
@ -320,20 +317,38 @@ public class OutGivingRun {
OutGivingRun.LOG_CACHE_MAP.put(outGivingId, logIdMap);
// 更新分发数据
OutGivingModel outGivingModel1 = new OutGivingModel();
outGivingModel1.setId(outGivingId);
outGivingModel1.setStatus(OutGivingModel.Status.ING.getCode());
outGivingServer.update(outGivingModel1);
updateStatus(outGivingId, OutGivingModel.Status.ING, null);
}
private void updateStatus(String outGivingId, OutGivingModel.Status status, String msg) {
private static void updateStatus(String outGivingId, OutGivingModel.Status status, String msg) {
OutGivingServer outGivingServer = SpringUtil.getBean(OutGivingServer.class);
OutGivingModel outGivingModel1 = new OutGivingModel();
outGivingModel1.setId(outGivingId);
outGivingModel1.setStatus(status.getCode());
outGivingModel1.setStatusMsg(msg);
outGivingServer.update(outGivingModel1);
//
OutGivingModel outGivingModel = outGivingServer.getByKey(outGivingId);
Opt.ofNullable(outGivingModel)
.map(outGivingModel2 ->
Opt.ofBlankAble(outGivingModel2.getWebhook())
.orElse(null))
.ifPresent(webhook ->
ThreadUtil.execute(() -> {
// outGivingIdoutGivingNamestatusstatusMsgexecuteTime
Map<String, Object> map = new HashMap<>(10);
map.put("outGivingId", outGivingId);
map.put("outGivingName", outGivingModel.getName());
map.put("status", status.getCode());
map.put("statusMsg", msg);
map.put("executeTime", SystemClock.now());
try {
IPlugin plugin = PluginFactory.getPlugin("webhook");
plugin.execute(webhook, map);
} catch (Exception e) {
log.error("WebHooks 调用错误", e);
}
}));
}
/**

View File

@ -3,3 +3,4 @@ ADD,PROJECT_INFO,group,String,50,,项目分组
ADD,OUT_GIVING,group,String,50,,项目分组
ADD,BUILD_INFO,buildEnvParameter,TEXT,,,构建环境变量
ADD,BUILDHISTORYLOG,buildEnvCache,TEXT,,,构建环境变量
ADD,OUT_GIVING,webhook,String,255,,webhook

1 alterType,tableName,name,type,len,defaultValue,comment,notNull
3 ADD,OUT_GIVING,group,String,50,,项目分组
4 ADD,BUILD_INFO,buildEnvParameter,TEXT,,,构建环境变量
5 ADD,BUILDHISTORYLOG,buildEnvCache,TEXT,,,构建环境变量
6 ADD,OUT_GIVING,webhook,String,255,,webhook

View File

@ -66,9 +66,8 @@ public class DefaultDockerPluginImpl implements IDockerConfigPlugin {
String type = main.toString();
if ("build".equals(type)) {
try (DockerBuild dockerBuild = new DockerBuild(parameter, this)) {
dockerBuild.build();
return dockerBuild.build();
}
return null;
}
Method method = ReflectUtil.getMethodByName(this.getClass(), type + "Cmd");
Assert.notNull(method, "不支持的类型:" + type);

View File

@ -30,6 +30,7 @@ import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.system.SystemUtil;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.command.CreateContainerCmd;
@ -66,7 +67,7 @@ public class DockerBuild implements AutoCloseable {
this.plugin = plugin;
}
public void build() {
public int build() {
File logFile = (File) parameter.get("logFile");
File tempDir = (File) parameter.get("tempDir");
@ -109,14 +110,15 @@ public class DockerBuild implements AutoCloseable {
dockerClient.startContainerCmd(containerId).exec();
} catch (RuntimeException e) {
logRecorder.error("容器启动失败:", e);
return;
return -101;
}
// 获取日志
this.pullLog(dockerClient, containerId, logRecorder);
// 等待容器执行结果
this.waitContainerCmd(dockerClient, containerId, logRecorder);
int statusCode = this.waitContainerCmd(dockerClient, containerId, logRecorder);
// 获取容器执行结果文件
DockerClientUtil.copyArchiveFromContainerCmd(dockerClient, containerId, logRecorder, resultFile, resultFileOut);
return statusCode;
} finally {
DockerClientUtil.removeContainerCmd(dockerClient, containerId);
// 删除临时目录
@ -208,12 +210,26 @@ public class DockerBuild implements AutoCloseable {
return;
}
for (String s : copy) {
// C:\Users\bwcx_\jpom\server\data\build\5c631117d4834dd4833c04dc1e6e635c\source:/home/jpom/:true
String resource;
String remotePath;
boolean dirChildrenOnly;
List<String> split = StrUtil.split(s, StrUtil.COLON);
logRecorder.system("send file to : {}\n", split.get(1));
if (SystemUtil.getOsInfo().isWindows() && StrUtil.length(split.get(0)) == 1) {
// 第一位是盘符
resource = split.get(0) + StrUtil.COLON + split.get(1);
remotePath = split.get(2);
dirChildrenOnly = Convert.toBool(CollUtil.get(split, 3), true);
} else {
resource = split.get(0);
remotePath = split.get(1);
dirChildrenOnly = Convert.toBool(CollUtil.get(split, 2), true);
}
logRecorder.system("send file from : {} to : {}", resource, remotePath);
dockerClient.copyArchiveToContainerCmd(containerId)
.withHostResource(split.get(0))
.withRemotePath(split.get(1))
.withDirChildrenOnly(Convert.toBool(CollUtil.get(split, 2), true))
.withHostResource(resource)
.withRemotePath(remotePath)
.withDirChildrenOnly(dirChildrenOnly)
.exec();
}
}
@ -227,7 +243,7 @@ public class DockerBuild implements AutoCloseable {
*/
private String generateBuildShell(List<Map<String, Object>> steps, String buildId) {
StringBuilder stepsScript = new StringBuilder("#!/bin/bash\n");
stepsScript.append("echo \"<<<<<<< Build Start >>>>>>>\"\n");
stepsScript.append("echo \"\n<<<<<<< Build Start >>>>>>>\"\n");
//
for (Map<String, Object> step : steps) {
if (step.containsKey("env")) {
@ -475,19 +491,23 @@ public class DockerBuild implements AutoCloseable {
}
private void waitContainerCmd(DockerClient dockerClient, String containerId, LogRecorder logRecorder) {
private int waitContainerCmd(DockerClient dockerClient, String containerId, LogRecorder logRecorder) {
final Integer[] statusCode = {-100};
try {
dockerClient.waitContainerCmd(containerId).exec(new ResultCallback.Adapter<WaitResponse>() {
@Override
public void onNext(WaitResponse object) {
logRecorder.system("dockerTask status code is: {}", object.getStatusCode());
statusCode[0] = object.getStatusCode();
logRecorder.system("dockerTask status code is: {}", statusCode[0]);
}
}).awaitCompletion();
} catch (InterruptedException e) {
logRecorder.error("获取容器执行结果操作被中断:", e);
} catch (RuntimeException e) {
logRecorder.error("获取容器执行结果失败", e);
}
return statusCode[0];
}
@Override

View File

@ -608,32 +608,44 @@
</a-tooltip>
</template>
<a-row>
<a-col :span="4">
<a-col :span="2">
<a-tooltip title="开启缓存构建目录将保留仓库文件,二次构建将 pull 代码, 不开启缓存目录每次构建都将重新拉取仓库代码(较大的项目不建议关闭缓存)">
<a-switch v-model="tempExtraData.cacheBuild" checked-children="" un-checked-children="" />
</a-tooltip>
</a-col>
<a-col :span="4" style="text-align: right">
<a-tooltip>
<template slot="title"> 保留产物是指对在构建完成后是否保留构建产物相关文件用于回滚 </template>
<a-col :span="6" style="text-align: right">
<a-space>
<a-tooltip>
<template slot="title"> 保留产物是指对在构建完成后是否保留构建产物相关文件用于回滚 </template>
<a-icon v-if="!temp.id" type="question-circle" theme="filled" />
保留产物
</a-tooltip>
<a-icon v-if="!temp.id" type="question-circle" theme="filled" />
保留产物
</a-tooltip>
<a-switch v-model="tempExtraData.saveBuildFile" checked-children="" un-checked-children="" />
</a-space>
</a-col>
<a-col :span="4">
<a-switch v-model="tempExtraData.saveBuildFile" checked-children="" un-checked-children="" />
</a-col>
<a-col :span="4" style="text-align: right">
<a-tooltip>
<template slot="title"> 差异构建是指构建时候是否判断仓库代码有变动如果没有变动则不执行构建 </template>
<a-icon v-if="!temp.id" type="question-circle" theme="filled" />
差异构建
</a-tooltip>
<a-col :span="6" style="text-align: right">
<a-space>
<a-tooltip>
<template slot="title"> 差异构建是指构建时候是否判断仓库代码有变动如果没有变动则不执行构建 </template>
<a-icon v-if="!temp.id" type="question-circle" theme="filled" />
差异构建
</a-tooltip>
<a-switch v-model="tempExtraData.checkRepositoryDiff" checked-children="" un-checked-children="" />
</a-space>
</a-col>
<a-col :span="4">
<a-switch v-model="tempExtraData.checkRepositoryDiff" checked-children="" un-checked-children="" />
<a-col :span="6" style="text-align: right">
<a-space>
<a-tooltip>
<template slot="title"> 严格执行脚本构建命令事件脚本本地发布脚本容器构建命令执行返回状态码必须是 0否则将构建状态标记为失败 </template>
<a-icon v-if="!temp.id" type="question-circle" theme="filled" />
严格执行
</a-tooltip>
<a-switch v-model="tempExtraData.strictlyEnforce" checked-children="" un-checked-children="" />
</a-space>
</a-col>
</a-row>
</a-form-model-item>

View File

@ -1,12 +0,0 @@
<template>
<div>Dashboard</div>
</template>
<script>
export default {
data() {
return {
}
}
}
</script>

View File

@ -328,6 +328,23 @@
</a-col>
</a-row>
</a-form-model-item>
<a-form-model-item prop="webhook">
<template slot="label">
WebHooks
<a-tooltip v-show="!temp.id">
<template slot="title">
<ul>
<li>分发过程请求对应的地址,开始分发,分发完成,分发失败,取消分发</li>
<li>传入参数有outGivingIdoutGivingNamestatusstatusMsgexecuteTime</li>
<li>status 的值有1:分发中2分发结束3已取消4分发失败</li>
<li>异步请求不能保证有序性</li>
</ul>
</template>
<a-icon type="question-circle" theme="filled" />
</a-tooltip>
</template>
<a-input v-model="temp.webhook" placeholder="构建过程请求,非必填GET请求" />
</a-form-model-item>
</a-form-model>
</a-modal>
<!-- 创建/编辑分发项目 -->
@ -617,6 +634,23 @@
</div>
</a-collapse-panel>
</a-collapse>
<a-form-model-item prop="webhook">
<template slot="label">
WebHooks
<a-tooltip v-show="!temp.id">
<template slot="title">
<ul>
<li>分发过程请求对应的地址,开始分发,分发完成,分发失败,取消分发</li>
<li>传入参数有outGivingIdoutGivingNamestatuserrorexecuteTime</li>
<li>status 的值有1:分发中2分发结束3已取消4分发失败</li>
<li>异步请求不能保证有序性</li>
</ul>
</template>
<a-icon type="question-circle" theme="filled" />
</a-tooltip>
</template>
<a-input v-model="temp.webhook" placeholder="构建过程请求,非必填GET请求" />
</a-form-model-item>
</a-form-model>
</a-modal>
<!-- 分发项目 -->

View File

@ -11,11 +11,11 @@ Router.prototype.push = function push(location) {
Vue.use(Router);
const children = [
{
path: "/dashboard",
name: "dashboard",
component: () => import("../pages/dashboard"),
},
// {
// path: "/dashboard",
// name: "dashboard",
// component: () => import("../pages/dashboard"),
// },
{
path: "/node/list",
name: "node-list",

View File

@ -510,6 +510,7 @@ export function compareVersion(version1, version2) {
return diff !== 0 ? diff : v1s.length - v2s.length;
}
// 当前页面构建信息
export function pageBuildInfo() {
const htmlVersion = document.head.querySelector("[name~=jpom-version][content]").content;
const buildTime = document.head.querySelector("[name~=build-time][content]").content;
@ -519,5 +520,6 @@ export function pageBuildInfo() {
t: buildTime,
e: buildEnv,
df: (document.title || "").toLowerCase().includes("jpom"),
t2: Date.now(),
};
}