修改开发容器保存功能,优化前端界面,增加转圈定时状态检测
Some checks are pending
DevStar Studio Auto Test Pipeline / unit-frontend-test (pull_request) Waiting to run
DevStar Studio Auto Test Pipeline / unit-backend-test (pull_request) Waiting to run
DevStar Studio CI/CD Pipeline / build-and-push-x86-64-docker-image (pull_request) Waiting to run
DevStar E2E Test / e2e-test (pull_request) Waiting to run

This commit is contained in:
2025-12-19 21:43:24 +08:00
parent d1fd7b8fb5
commit 47ea96bff4
10 changed files with 271 additions and 57 deletions

View File

@@ -3906,6 +3906,8 @@ management = Secrets Management
[devcontainer]
manage = Container Management
registry_username = Registry Username
registry_password = Registry Password
scripts = ShellScripts
scripts.management = ShellScripts Management
scripts.creation = Add ShellScript

View File

@@ -3897,6 +3897,8 @@ management=密钥管理
[devcontainer]
manage=容器管理
registry_username = 镜像仓库用户名
registry_password = 镜像仓库密码
scripts=脚本管理
scripts.management=脚本管理
scripts.creation=添加脚本

View File

@@ -73,9 +73,15 @@ func GetDevContainerDetails(ctx *context.Context) {
imageName := configurationModel.Image
registry, namespace, repo, tag := devcontainer_service.ParseImageName(imageName)
log.Info("%v %v", repo, tag)
// 保留这些数据用于向后兼容,但不再在表单中使用
ctx.Data["RepositoryAddress"] = registry
ctx.Data["RepositoryUsername"] = namespace
ctx.Data["ImageName"] = "dev-" + ctx.Repo.Repository.Name + ":latest"
// 默认 ImageName 使用完整路径格式
if namespace != "" {
ctx.Data["ImageName"] = registry + "/" + namespace + "/dev-" + ctx.Repo.Repository.Name + ":latest"
} else {
ctx.Data["ImageName"] = registry + "/dev-" + ctx.Repo.Repository.Name + ":latest"
}
}
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
// 获取WebSSH服务端口

View File

@@ -710,6 +710,8 @@ func UpdateDevContainer(ctx context.Context, doer *user.User, repo *gitea_contex
if err != nil {
return err
}
// 保存原始状态,以便出错时回滚
originalStatus := devContainerInfo.DevcontainerStatus
_, err = dbEngine.Table("devcontainer").
Where("user_id = ? AND repo_id = ? ", doer.ID, repo.Repository.ID).
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 5})
@@ -721,15 +723,22 @@ func UpdateDevContainer(ctx context.Context, doer *user.User, repo *gitea_contex
//k8s的逻辑
} else {
updateErr := UpdateDevContainerByDocker(otherCtx, &devContainerInfo, updateInfo, repo, doer)
if updateErr != nil {
// 出错时回滚状态
_, rollbackErr := dbEngine.Table("devcontainer").
Where("user_id = ? AND repo_id = ? ", doer.ID, repo.Repository.ID).
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: originalStatus})
if rollbackErr != nil {
log.Error("Failed to rollback devcontainer status: %v", rollbackErr)
}
return updateErr
}
_, err = dbEngine.Table("devcontainer").
Where("user_id = ? AND repo_id = ? ", doer.ID, repo.Repository.ID).
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 4})
if err != nil {
return err
}
if updateErr != nil {
return updateErr
}
}
return nil

View File

@@ -1,3 +1,10 @@
/*
* Copyright (c) Mengning Software. 2025. All rights reserved.
* Authors: DevStar Team
* Create: 2025-12-19
* Description: DevContainer Management - Type Definitions.
*/
package devcontainer
import (

View File

@@ -13,7 +13,7 @@ import (
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/user"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
@@ -24,7 +24,7 @@ import (
"github.com/google/uuid"
)
func IsAdmin(ctx context.Context, doer *user.User, repoID int64) (bool, error) {
func IsAdmin(ctx context.Context, doer *user_model.User, repoID int64) (bool, error) {
if doer.IsAdmin {
return true, nil
}

View File

@@ -404,7 +404,27 @@ func UpdateDevContainerByDocker(ctx context.Context, devContainerInfo *devcontai
}
defer cli.Close()
// update容器
imageRef := updateInfo.RepositoryAddress + "/" + updateInfo.RepositoryUsername + "/" + updateInfo.ImageName
// ImageName 应该是完整路径例如devstar.cn/devstar/image-name:tag 或 docker.io/username/image:tag
// 直接使用 ImageName 作为 imageRef
imageRef := updateInfo.ImageName
// 从 ImageName 解析 registry 用于登录
// 格式registry/namespace/image:tag 或 registry/image:tag
var registryUrl string
if strings.Contains(updateInfo.ImageName, "/") {
parts := strings.Split(updateInfo.ImageName, "/")
firstPart := parts[0]
// 如果第一部分包含点或端口认为是registry地址
if strings.Contains(firstPart, ".") || strings.Contains(firstPart, ":") || strings.EqualFold(firstPart, "localhost") {
registryUrl = firstPart
} else {
// 否则默认使用 docker.io
registryUrl = "docker.io"
}
} else {
// 没有斜杠,默认使用 docker.io
registryUrl = "docker.io"
}
configurationString, err := GetDevcontainerConfigurationString(ctx, repo.Repository)
if err != nil {
return err
@@ -497,7 +517,7 @@ func UpdateDevContainerByDocker(ctx context.Context, devContainerInfo *devcontai
if err != nil {
return err
}
err = docker_module.PushImage(dockerHost, updateInfo.RepositoryUsername, updateInfo.PassWord, updateInfo.RepositoryAddress, imageRef)
err = docker_module.PushImage(dockerHost, updateInfo.RepositoryUsername, updateInfo.PassWord, registryUrl, imageRef)
if err != nil {
return err
}

View File

@@ -264,10 +264,8 @@ function getStatus() {
}
// 容器运行中时继续轮询,以检测状态变化(如被停止、重启等)
}else if (data.status == '5') {
concealElement();
if (loadingElement) {
loadingElement.style.display = 'block';
}
// 状态5正在保存镜像不影响容器运行什么都不改变
// 保存镜像只是构建/提交镜像、推送到Registry、更新配置不影响正在运行的容器
}else if (data.status == '6') {
concealElement();
if (deleteContainer){
@@ -399,13 +397,12 @@ document.getElementById('customAlert').addEventListener('click', function(e) {
// 使用共享的保存模态框功能
document.getElementById('updateSubmitButton').addEventListener('click', function() {
const targetUrl = '{{.Repository.Link}}/devcontainer/update';
submitSaveForm(targetUrl, function(data) {
const statusUrl = '{{.Repository.Link}}/devcontainer/status';
submitSaveForm(targetUrl, statusUrl, function(data) {
// 成功回调
showCustomAlert(data.message);
if(data.redirect){
closeCustomAlert();
}
intervalID = setInterval(getStatus, 3000);
// 不需要重新启动状态检测,因为已经在运行
// 只需要立即刷新一次状态
getStatus();
}, function(error) {
// 错误回调
console.error('Save failed:', error);

View File

@@ -346,14 +346,15 @@
return;
}
// 打开模态框
openSaveModal();
// 打开模态框会自动检查Dockerfile是否存在
openSaveModal(repoLink);
// 绑定提交按钮事件(每次打开时重新绑定)
const submitButton = document.getElementById('updateSubmitButton');
submitButton.onclick = function() {
const targetUrl = '{{AppSubUrl}}' + repoLink + '/devcontainer/update';
submitSaveForm(targetUrl, function(data) {
const statusUrl = '{{AppSubUrl}}' + repoLink + '/devcontainer/status';
submitSaveForm(targetUrl, statusUrl, function(data) {
// 成功后刷新列表
refreshContainerList();
}, function(error) {

View File

@@ -3,19 +3,19 @@
<div class="header">
保存为容器镜像
</div>
<div class="content">
<div class="content" style="position: relative;">
<form class="ui form tw-max-w-2xl tw-m-auto" id="updateForm">
<div class="required field ">
<label for="RepositoryAddress">Registry:</label>
<input style="border: 1px solid black;" type="text" id="RepositoryAddress" name="RepositoryAddress" placeholder="例如: docker.io" value="{{.RepositoryAddress}}">
<label for="ImageName">Image Name:</label>
<input style="border: 1px solid black;" type="text" id="ImageName" name="ImageName" placeholder="devstar.cn/devstar/image-name:tag" value="{{.ImageName}}">
</div>
<div class="required field ">
<label for="RepositoryUsername">Registry Username:</label>
<input style="border: 1px solid black;" type="text" id="RepositoryUsername" name="RepositoryUsername" placeholder="镜像仓库用户名" value="{{.RepositoryUsername}}">
<label for="RepositoryUsername">{{ctx.Locale.Tr "devcontainer.registry_username"}}:</label>
<input style="border: 1px solid black;" type="text" id="RepositoryUsername" name="RepositoryUsername" placeholder="{{ctx.Locale.Tr "devcontainer.registry_username"}}" value="{{.RepositoryUsername}}">
</div>
<div class="required field ">
<label for="RepositoryPassword">Registry Password:</label>
<label for="RepositoryPassword">{{ctx.Locale.Tr "devcontainer.registry_password"}}:</label>
<div style="position: relative; display: inline-block; width: 100%;">
<input style="border: 1px solid black; width: 100%; padding-right: 80px;"
type="password"
@@ -23,7 +23,7 @@
name="RepositoryPassword"
required
autocomplete="current-password"
placeholder="镜像仓库密码">
placeholder="{{ctx.Locale.Tr "devcontainer.registry_password"}}">
<button type="button"
style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer; color: #666;
@@ -33,27 +33,31 @@
</button>
</div>
</div>
<div class="required field ">
<label for="ImageName">Image(name:tag):</label>
<input style="border: 1px solid black;" type="text" id="ImageName" name="ImageName" placeholder="例如: myimage:latest" value="{{.ImageName}}">
</div>
<div class="inline field">
<div class="ui checkbox">
{{if .HasDevContainerDockerfile}}
<input type="checkbox" id="SaveMethod" name="SaveMethod" value="on" checked>
<label for="SaveMethod">Build From Dockerfile{{if .DockerfilePath}}: {{.DockerfilePath}}{{end}}</label>
<label for="SaveMethod">Build From Dockerfile{{if .DockerfilePath}}: <a id="dockerfileLinkStatic" href="{{.Repository.Link}}/_edit/{{.Repository.DefaultBranch | PathEscapeSegments}}/{{.DockerfilePath | PathEscapeSegments}}" target="_blank" style="color: #2185d0; text-decoration: underline;">{{.DockerfilePath}}</a>{{end}}<span id="dockerfilePathSpan" style="display: none;">: <a id="dockerfileLink" href="#" target="_blank" style="color: #2185d0; text-decoration: underline;"></a></span></label>
{{else}}
<input type="checkbox" id="SaveMethod" name="SaveMethod" value="on" checked>
<label for="SaveMethod">Build From Dockerfile</label>
<label for="SaveMethod">Build From Dockerfile<span id="dockerfilePathSpan" style="display: none;">: <a id="dockerfileLink" href="#" target="_blank" style="color: #2185d0; text-decoration: underline;"></a></span></label>
{{end}}
</div>
<div style="color: #2185d0; font-size: 13px; margin-top: 12px; padding: 10px; background-color: #f0f8ff; border-left: 3px solid #2185d0; border-radius: 3px;">
<strong>💡 提示:</strong>建议优先使用 <strong>Build From Dockerfile</strong> 方式保存镜像。从容器直接保存镜像不透明且存储臃肿而通过Dockerfile构建可以确保镜像的可重现性和最小化。
</div>
</div>
<div class="actions">
<button class="ui primary button" type="button" id="updateSubmitButton">提交</button>
<button class="ui cancel button" type="button" id="updateCloseButton">取消</button>
</div>
</form>
<!-- Loading 遮罩层(初始隐藏) -->
<div id="saveLoadingOverlay" class="save-loading-overlay" style="display: none;">
<div class="save-loading"></div>
</div>
</div>
</div>
@@ -77,27 +81,34 @@
};
// 提交保存表单
window.submitSaveForm = function(targetUrl, onSuccess, onError) {
// targetUrl: 保存请求的URL
// statusUrl: 状态检查URL用于轮询
// onSuccess: 成功回调
// onError: 错误回调
window.submitSaveForm = function(targetUrl, statusUrl, onSuccess, onError) {
const form = document.getElementById('updateForm');
const formData = new FormData(form);
const submitButton = document.getElementById('updateSubmitButton');
const modal = document.getElementById('updatemodal');
const cancelButton = document.getElementById('updateCloseButton');
const loadingOverlay = document.getElementById('saveLoadingOverlay');
const RepositoryAddress = formData.get('RepositoryAddress');
const RepositoryUsername = formData.get('RepositoryUsername');
const RepositoryPassword = formData.get('RepositoryPassword');
const SaveMethod = formData.get('SaveMethod');
const ImageName = formData.get('ImageName');
// 验证必填字段
if (!ImageName || !RepositoryAddress || !RepositoryUsername || !RepositoryPassword) {
if (!ImageName || !RepositoryUsername || !RepositoryPassword) {
alert('请填写所有必填字段');
return;
}
// 显示加载状态
// 显示加载状态(遮罩层覆盖整个表单)
if (loadingOverlay) {
loadingOverlay.style.display = 'flex';
}
submitButton.disabled = true;
$('#updatemodal').addClass('loading');
cancelButton.disabled = true;
const csrfToken = document.querySelector('meta[name="_csrf"]')?.content ||
(window.config && window.config.csrfToken);
@@ -110,7 +121,6 @@
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({
RepositoryAddress: RepositoryAddress,
RepositoryUsername: RepositoryUsername,
RepositoryPassword: RepositoryPassword,
SaveMethod: SaveMethod || '',
@@ -119,43 +129,171 @@
}).then(function(response) {
return response.json();
}).then(function(data) {
submitButton.disabled = false;
$('#updatemodal').removeClass('loading');
if (data.message) {
alert(data.message);
// 检查是否有错误
if (data.message && data.message !== '成功' && !data.redirect) {
// 立即返回错误
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
submitButton.disabled = false;
cancelButton.disabled = false;
alert('保存失败:' + data.message);
if (onError) {
onError(data);
}
return;
}
if (data.message === '成功' || data.redirect) {
// 关闭模态框
closeSaveModal();
// 调用成功回调
// 提交成功,开始轮询状态
if (statusUrl) {
startStatusPolling(statusUrl, onSuccess, onError, submitButton, cancelButton, loadingOverlay);
} else {
// 没有状态URL直接显示成功
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
submitButton.disabled = false;
cancelButton.disabled = false;
alert('保存成功!');
if (onSuccess) {
closeSaveModal();
onSuccess(data);
}
} else if (onError) {
onError(data);
}
}).catch(function(error) {
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
submitButton.disabled = false;
$('#updatemodal').removeClass('loading');
cancelButton.disabled = false;
console.error('Save error:', error);
alert('保存容器失败');
alert('保存失败:' + (error.message || '未知错误'));
if (onError) {
onError(error);
}
});
};
// 轮询状态
function startStatusPolling(statusUrl, onSuccess, onError, submitButton, cancelButton, loadingOverlay) {
let pollCount = 0;
const maxPolls = 300; // 最多轮询5分钟
const pollInterval = setInterval(function() {
pollCount++;
// 超时检查
if (pollCount > maxPolls) {
clearInterval(pollInterval);
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
submitButton.disabled = false;
cancelButton.disabled = false;
alert('操作超时,请检查容器状态');
return;
}
fetch(statusUrl)
.then(function(response) {
return response.json();
})
.then(function(data) {
const status = String(data.status || '');
if (status === '5') {
// 正在保存,继续转圈
return;
} else if (status === '4') {
// 完成
clearInterval(pollInterval);
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
submitButton.disabled = false;
cancelButton.disabled = false;
alert('保存成功镜像已推送到Registry');
if (onSuccess) {
closeSaveModal();
onSuccess({message: '成功'});
}
} else {
// 其他状态,可能是错误
clearInterval(pollInterval);
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
submitButton.disabled = false;
cancelButton.disabled = false;
alert('保存失败:状态异常(' + status + '');
if (onError) {
onError({message: '状态异常:' + status});
}
}
})
.catch(function(error) {
console.error('Status poll error:', error);
// 轮询错误不中断,继续尝试
});
}, 3000); // 每3秒轮询一次
}
// 打开模态框
window.openSaveModal = function() {
window.openSaveModal = function(repoLink, dockerfilePath) {
// 如果提供了repoLink尝试检查Dockerfile是否存在管理页面场景
if (repoLink) {
const dockerfileLink = document.getElementById('dockerfileLink');
const dockerfileLinkStatic = document.getElementById('dockerfileLinkStatic');
const dockerfilePathSpan = document.getElementById('dockerfilePathSpan');
// 如果已经有静态链接(详情页场景),不需要动态添加
if (dockerfileLinkStatic) {
// 详情页已经有链接隐藏动态span
if (dockerfilePathSpan) {
dockerfilePathSpan.style.display = 'none';
}
} else {
// 管理页面场景,需要动态检查并添加链接
// 先隐藏Dockerfile链接
if (dockerfilePathSpan) {
dockerfilePathSpan.style.display = 'none';
}
// 如果提供了dockerfilePath直接使用否则尝试检查默认路径
const pathToCheck = dockerfilePath || '.devcontainer/Dockerfile';
const defaultBranch = 'main'; // 可以后续优化为从API获取
// 使用HEAD请求检查文件是否存在更轻量
fetch(repoLink + '/raw/branch/' + defaultBranch + '/' + encodeURIComponent(pathToCheck), {
method: 'HEAD'
}).then(function(response) {
// 只有文件存在200 OK且找到了元素才显示链接
if (response.ok && response.status === 200 && dockerfileLink && dockerfilePathSpan) {
// 文件存在,显示链接
const editUrl = repoLink + '/_edit/' + encodeURIComponent(defaultBranch) + '/' + encodeURIComponent(pathToCheck);
dockerfileLink.href = editUrl;
dockerfileLink.textContent = pathToCheck;
dockerfilePathSpan.style.display = 'inline';
}
// 如果文件不存在404保持隐藏状态
}).catch(function(error) {
// 检查失败,不显示链接
console.log('Dockerfile check failed:', error);
});
}
}
$('#updatemodal').modal('show');
};
// 关闭模态框
window.closeSaveModal = function() {
$('#updatemodal').modal('hide');
document.getElementById('updateForm').reset();
const form = document.getElementById('updateForm');
const loadingOverlay = document.getElementById('saveLoadingOverlay');
form.reset();
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
};
// 关闭按钮事件
@@ -166,6 +304,38 @@
closeSaveModal();
});
}
})();
})();
</script>
<style>
.save-loading-overlay{
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.save-loading{
width:60px;
height:60px;
border-radius:150px;
border:8px solid #f3f3f3;
border-top-color:rgba(0,0,0,0.3);
box-sizing:border-box;
animation:save-loading 1.2s linear infinite;
-webkit-animation:save-loading 1.2s linear infinite;
}
@keyframes save-loading{
0%{transform:rotate(0deg)}
100%{transform:rotate(360deg)}
}
@-webkit-keyframes save-loading{
0%{-webkit-transform:rotate(0deg)}
100%{-webkit-transform:rotate(360deg)}
}
</style>