mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-10-31 22:38:58 +00:00
Artifact deletion in actions ui (#27172)
Add deletion link in runs view page. Fix #26315 ![image](https://github.com/go-gitea/gitea/assets/2142787/aa65a4ab-f434-4deb-b953-21e63c212033) When click deletion button. It marks this artifact `need-delete`. This artifact would be deleted when actions cleanup cron task.
This commit is contained in:
parent
c47e6ceb82
commit
c551d3f3ab
8 changed files with 120 additions and 11 deletions
|
@ -26,6 +26,8 @@ const (
|
||||||
ArtifactStatusUploadConfirmed // 2, ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed
|
ArtifactStatusUploadConfirmed // 2, ArtifactStatusUploadConfirmed is the status of an artifact upload that is confirmed
|
||||||
ArtifactStatusUploadError // 3, ArtifactStatusUploadError is the status of an artifact upload that is errored
|
ArtifactStatusUploadError // 3, ArtifactStatusUploadError is the status of an artifact upload that is errored
|
||||||
ArtifactStatusExpired // 4, ArtifactStatusExpired is the status of an artifact that is expired
|
ArtifactStatusExpired // 4, ArtifactStatusExpired is the status of an artifact that is expired
|
||||||
|
ArtifactStatusPendingDeletion // 5, ArtifactStatusPendingDeletion is the status of an artifact that is pending deletion
|
||||||
|
ArtifactStatusDeleted // 6, ArtifactStatusDeleted is the status of an artifact that is deleted
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
@ -147,8 +149,28 @@ func ListNeedExpiredArtifacts(ctx context.Context) ([]*ActionArtifact, error) {
|
||||||
Where("expired_unix < ? AND status = ?", timeutil.TimeStamp(time.Now().Unix()), ArtifactStatusUploadConfirmed).Find(&arts)
|
Where("expired_unix < ? AND status = ?", timeutil.TimeStamp(time.Now().Unix()), ArtifactStatusUploadConfirmed).Find(&arts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPendingDeleteArtifacts returns all artifacts in pending-delete status.
|
||||||
|
// limit is the max number of artifacts to return.
|
||||||
|
func ListPendingDeleteArtifacts(ctx context.Context, limit int) ([]*ActionArtifact, error) {
|
||||||
|
arts := make([]*ActionArtifact, 0, limit)
|
||||||
|
return arts, db.GetEngine(ctx).
|
||||||
|
Where("status = ?", ArtifactStatusPendingDeletion).Limit(limit).Find(&arts)
|
||||||
|
}
|
||||||
|
|
||||||
// SetArtifactExpired sets an artifact to expired
|
// SetArtifactExpired sets an artifact to expired
|
||||||
func SetArtifactExpired(ctx context.Context, artifactID int64) error {
|
func SetArtifactExpired(ctx context.Context, artifactID int64) error {
|
||||||
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusExpired)})
|
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusExpired)})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetArtifactNeedDelete sets an artifact to need-delete, cron job will delete it
|
||||||
|
func SetArtifactNeedDelete(ctx context.Context, runID int64, name string) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusPendingDeletion)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetArtifactDeleted sets an artifact to deleted
|
||||||
|
func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusDeleted)})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
@ -124,6 +124,7 @@ pin = Pin
|
||||||
unpin = Unpin
|
unpin = Unpin
|
||||||
|
|
||||||
artifacts = Artifacts
|
artifacts = Artifacts
|
||||||
|
confirm_delete_artifact = Are you sure you want to delete the artifact '%s' ?
|
||||||
|
|
||||||
archived = Archived
|
archived = Archived
|
||||||
|
|
||||||
|
|
|
@ -98,15 +98,16 @@ type ViewRequest struct {
|
||||||
type ViewResponse struct {
|
type ViewResponse struct {
|
||||||
State struct {
|
State struct {
|
||||||
Run struct {
|
Run struct {
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CanCancel bool `json:"canCancel"`
|
CanCancel bool `json:"canCancel"`
|
||||||
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
|
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
|
||||||
CanRerun bool `json:"canRerun"`
|
CanRerun bool `json:"canRerun"`
|
||||||
Done bool `json:"done"`
|
CanDeleteArtifact bool `json:"canDeleteArtifact"`
|
||||||
Jobs []*ViewJob `json:"jobs"`
|
Done bool `json:"done"`
|
||||||
Commit ViewCommit `json:"commit"`
|
Jobs []*ViewJob `json:"jobs"`
|
||||||
|
Commit ViewCommit `json:"commit"`
|
||||||
} `json:"run"`
|
} `json:"run"`
|
||||||
CurrentJob struct {
|
CurrentJob struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
@ -187,6 +188,7 @@ func ViewPost(ctx *context_module.Context) {
|
||||||
resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||||
resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
|
resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
|
||||||
resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||||
|
resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
|
||||||
resp.State.Run.Done = run.Status.IsDone()
|
resp.State.Run.Done = run.Status.IsDone()
|
||||||
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
|
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
|
||||||
resp.State.Run.Status = run.Status.String()
|
resp.State.Run.Status = run.Status.String()
|
||||||
|
@ -576,6 +578,29 @@ func ArtifactsView(ctx *context_module.Context) {
|
||||||
ctx.JSON(http.StatusOK, artifactsResponse)
|
ctx.JSON(http.StatusOK, artifactsResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ArtifactsDeleteView(ctx *context_module.Context) {
|
||||||
|
if !ctx.Repo.CanWrite(unit.TypeActions) {
|
||||||
|
ctx.Error(http.StatusForbidden, "no permission")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runIndex := ctx.ParamsInt64("run")
|
||||||
|
artifactName := ctx.Params("artifact_name")
|
||||||
|
|
||||||
|
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool {
|
||||||
|
return errors.Is(err, util.ErrNotExist)
|
||||||
|
}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, struct{}{})
|
||||||
|
}
|
||||||
|
|
||||||
func ArtifactsDownloadView(ctx *context_module.Context) {
|
func ArtifactsDownloadView(ctx *context_module.Context) {
|
||||||
runIndex := ctx.ParamsInt64("run")
|
runIndex := ctx.ParamsInt64("run")
|
||||||
artifactName := ctx.Params("artifact_name")
|
artifactName := ctx.Params("artifact_name")
|
||||||
|
@ -603,6 +628,14 @@ func ArtifactsDownloadView(ctx *context_module.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if artifacts status is not uploaded-confirmed, treat it as not found
|
||||||
|
for _, art := range artifacts {
|
||||||
|
if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) {
|
||||||
|
ctx.Error(http.StatusNotFound, "artifact not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
|
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
|
||||||
|
|
||||||
writer := zip.NewWriter(ctx.Resp)
|
writer := zip.NewWriter(ctx.Resp)
|
||||||
|
|
|
@ -1401,6 +1401,7 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Post("/approve", reqRepoActionsWriter, actions.Approve)
|
m.Post("/approve", reqRepoActionsWriter, actions.Approve)
|
||||||
m.Post("/artifacts", actions.ArtifactsView)
|
m.Post("/artifacts", actions.ArtifactsView)
|
||||||
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
|
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
|
||||||
|
m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView)
|
||||||
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
|
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -20,8 +20,15 @@ func Cleanup(taskCtx context.Context, olderThan time.Duration) error {
|
||||||
return CleanupArtifacts(taskCtx)
|
return CleanupArtifacts(taskCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupArtifacts removes expired artifacts and set records expired status
|
// CleanupArtifacts removes expired add need-deleted artifacts and set records expired status
|
||||||
func CleanupArtifacts(taskCtx context.Context) error {
|
func CleanupArtifacts(taskCtx context.Context) error {
|
||||||
|
if err := cleanExpiredArtifacts(taskCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cleanNeedDeleteArtifacts(taskCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanExpiredArtifacts(taskCtx context.Context) error {
|
||||||
artifacts, err := actions.ListNeedExpiredArtifacts(taskCtx)
|
artifacts, err := actions.ListNeedExpiredArtifacts(taskCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -40,3 +47,32 @@ func CleanupArtifacts(taskCtx context.Context) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteArtifactBatchSize is the batch size of deleting artifacts
|
||||||
|
const deleteArtifactBatchSize = 100
|
||||||
|
|
||||||
|
func cleanNeedDeleteArtifacts(taskCtx context.Context) error {
|
||||||
|
for {
|
||||||
|
artifacts, err := actions.ListPendingDeleteArtifacts(taskCtx, deleteArtifactBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Info("Found %d artifacts pending deletion", len(artifacts))
|
||||||
|
for _, artifact := range artifacts {
|
||||||
|
if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
|
||||||
|
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := actions.SetArtifactDeleted(taskCtx, artifact.ID); err != nil {
|
||||||
|
log.Error("Cannot set artifact %d deleted: %v", artifact.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Info("Artifact %d set deleted", artifact.ID)
|
||||||
|
}
|
||||||
|
if len(artifacts) < deleteArtifactBatchSize {
|
||||||
|
log.Debug("No more artifacts pending deletion")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
data-locale-status-skipped="{{ctx.Locale.Tr "actions.status.skipped"}}"
|
data-locale-status-skipped="{{ctx.Locale.Tr "actions.status.skipped"}}"
|
||||||
data-locale-status-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}"
|
data-locale-status-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}"
|
||||||
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
|
||||||
|
data-locale-confirm-delete-artifact="{{ctx.Locale.Tr "confirm_delete_artifact"}}"
|
||||||
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
data-locale-show-timestamps="{{ctx.Locale.Tr "show_timestamps"}}"
|
||||||
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
|
||||||
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
|
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {createApp} from 'vue';
|
||||||
import {toggleElem} from '../utils/dom.js';
|
import {toggleElem} from '../utils/dom.js';
|
||||||
import {getCurrentLocale} from '../utils.js';
|
import {getCurrentLocale} from '../utils.js';
|
||||||
import {renderAnsi} from '../render/ansi.js';
|
import {renderAnsi} from '../render/ansi.js';
|
||||||
import {POST} from '../modules/fetch.js';
|
import {POST, DELETE} from '../modules/fetch.js';
|
||||||
|
|
||||||
const sfc = {
|
const sfc = {
|
||||||
name: 'RepoActionView',
|
name: 'RepoActionView',
|
||||||
|
@ -200,6 +200,12 @@ const sfc = {
|
||||||
return await resp.json();
|
return await resp.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteArtifact(name) {
|
||||||
|
if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
|
||||||
|
await DELETE(`${this.run.link}/artifacts/${name}`);
|
||||||
|
await this.loadJob();
|
||||||
|
},
|
||||||
|
|
||||||
async fetchJob() {
|
async fetchJob() {
|
||||||
const logCursors = this.currentJobStepsStates.map((it, idx) => {
|
const logCursors = this.currentJobStepsStates.map((it, idx) => {
|
||||||
// cursor is used to indicate the last position of the logs
|
// cursor is used to indicate the last position of the logs
|
||||||
|
@ -329,6 +335,8 @@ export function initRepositoryActionView() {
|
||||||
cancel: el.getAttribute('data-locale-cancel'),
|
cancel: el.getAttribute('data-locale-cancel'),
|
||||||
rerun: el.getAttribute('data-locale-rerun'),
|
rerun: el.getAttribute('data-locale-rerun'),
|
||||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||||
|
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||||
|
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||||
rerun_all: el.getAttribute('data-locale-rerun-all'),
|
rerun_all: el.getAttribute('data-locale-rerun-all'),
|
||||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||||
|
@ -404,6 +412,9 @@ export function initRepositoryActionView() {
|
||||||
<a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
|
<a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
|
||||||
<SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
|
<SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
|
||||||
</a>
|
</a>
|
||||||
|
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete">
|
||||||
|
<SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -528,6 +539,8 @@ export function initRepositoryActionView() {
|
||||||
.job-artifacts-item {
|
.job-artifacts-item {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-artifacts-list {
|
.job-artifacts-list {
|
||||||
|
|
|
@ -67,6 +67,7 @@ import octiconStrikethrough from '../../public/assets/img/svg/octicon-strikethro
|
||||||
import octiconSync from '../../public/assets/img/svg/octicon-sync.svg';
|
import octiconSync from '../../public/assets/img/svg/octicon-sync.svg';
|
||||||
import octiconTable from '../../public/assets/img/svg/octicon-table.svg';
|
import octiconTable from '../../public/assets/img/svg/octicon-table.svg';
|
||||||
import octiconTag from '../../public/assets/img/svg/octicon-tag.svg';
|
import octiconTag from '../../public/assets/img/svg/octicon-tag.svg';
|
||||||
|
import octiconTrash from '../../public/assets/img/svg/octicon-trash.svg';
|
||||||
import octiconTriangleDown from '../../public/assets/img/svg/octicon-triangle-down.svg';
|
import octiconTriangleDown from '../../public/assets/img/svg/octicon-triangle-down.svg';
|
||||||
import octiconX from '../../public/assets/img/svg/octicon-x.svg';
|
import octiconX from '../../public/assets/img/svg/octicon-x.svg';
|
||||||
import octiconXCircleFill from '../../public/assets/img/svg/octicon-x-circle-fill.svg';
|
import octiconXCircleFill from '../../public/assets/img/svg/octicon-x-circle-fill.svg';
|
||||||
|
@ -139,6 +140,7 @@ const svgs = {
|
||||||
'octicon-sync': octiconSync,
|
'octicon-sync': octiconSync,
|
||||||
'octicon-table': octiconTable,
|
'octicon-table': octiconTable,
|
||||||
'octicon-tag': octiconTag,
|
'octicon-tag': octiconTag,
|
||||||
|
'octicon-trash': octiconTrash,
|
||||||
'octicon-triangle-down': octiconTriangleDown,
|
'octicon-triangle-down': octiconTriangleDown,
|
||||||
'octicon-x': octiconX,
|
'octicon-x': octiconX,
|
||||||
'octicon-x-circle-fill': octiconXCircleFill,
|
'octicon-x-circle-fill': octiconXCircleFill,
|
||||||
|
|
Loading…
Reference in a new issue