mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 19:38:23 +00:00 
			
		
		
		
	Artifact deletion in actions ui (#27172)
Add deletion link in runs view page. Fix #26315  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:
		| @@ -26,6 +26,8 @@ const ( | ||||
| 	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 | ||||
| 	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() { | ||||
| @@ -147,8 +149,28 @@ func ListNeedExpiredArtifacts(ctx context.Context) ([]*ActionArtifact, error) { | ||||
| 		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 | ||||
| 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)}) | ||||
| 	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 | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,7 @@ pin = Pin | ||||
| unpin = Unpin | ||||
|  | ||||
| artifacts = Artifacts | ||||
| confirm_delete_artifact = Are you sure you want to delete the artifact '%s' ? | ||||
|  | ||||
| archived = Archived | ||||
|  | ||||
|   | ||||
| @@ -63,6 +63,7 @@ type ViewResponse struct { | ||||
| 			CanCancel         bool       `json:"canCancel"` | ||||
| 			CanApprove        bool       `json:"canApprove"` // the run needs an approval and the doer has permission to approve | ||||
| 			CanRerun          bool       `json:"canRerun"` | ||||
| 			CanDeleteArtifact bool       `json:"canDeleteArtifact"` | ||||
| 			Done              bool       `json:"done"` | ||||
| 			Jobs              []*ViewJob `json:"jobs"` | ||||
| 			Commit            ViewCommit `json:"commit"` | ||||
| @@ -146,6 +147,7 @@ func ViewPost(ctx *context_module.Context) { | ||||
| 	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.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.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json | ||||
| 	resp.State.Run.Status = run.Status.String() | ||||
| @@ -535,6 +537,29 @@ func ArtifactsView(ctx *context_module.Context) { | ||||
| 	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) { | ||||
| 	runIndex := ctx.ParamsInt64("run") | ||||
| 	artifactName := ctx.Params("artifact_name") | ||||
| @@ -562,6 +587,14 @@ func ArtifactsDownloadView(ctx *context_module.Context) { | ||||
| 		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)) | ||||
|  | ||||
| 	writer := zip.NewWriter(ctx.Resp) | ||||
|   | ||||
| @@ -1368,6 +1368,7 @@ func registerRoutes(m *web.Route) { | ||||
| 				m.Post("/approve", reqRepoActionsWriter, actions.Approve) | ||||
| 				m.Post("/artifacts", actions.ArtifactsView) | ||||
| 				m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) | ||||
| 				m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView) | ||||
| 				m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) | ||||
| 			}) | ||||
| 		}, reqRepoActionsReader, actions.MustEnableActions) | ||||
|   | ||||
| @@ -20,8 +20,15 @@ func Cleanup(taskCtx context.Context, olderThan time.Duration) error { | ||||
| 	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 { | ||||
| 	if err := cleanExpiredArtifacts(taskCtx); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return cleanNeedDeleteArtifacts(taskCtx) | ||||
| } | ||||
|  | ||||
| func cleanExpiredArtifacts(taskCtx context.Context) error { | ||||
| 	artifacts, err := actions.ListNeedExpiredArtifacts(taskCtx) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| @@ -40,3 +47,32 @@ func CleanupArtifacts(taskCtx context.Context) error { | ||||
| 	} | ||||
| 	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-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}" | ||||
| 		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-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}" | ||||
| 		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 {getCurrentLocale} from '../utils.js'; | ||||
| import {renderAnsi} from '../render/ansi.js'; | ||||
| import {POST} from '../modules/fetch.js'; | ||||
| import {POST, DELETE} from '../modules/fetch.js'; | ||||
|  | ||||
| const sfc = { | ||||
|   name: 'RepoActionView', | ||||
| @@ -200,6 +200,12 @@ const sfc = { | ||||
|       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() { | ||||
|       const logCursors = this.currentJobStepsStates.map((it, idx) => { | ||||
|         // cursor is used to indicate the last position of the logs | ||||
| @@ -329,6 +335,8 @@ export function initRepositoryActionView() { | ||||
|       cancel: el.getAttribute('data-locale-cancel'), | ||||
|       rerun: el.getAttribute('data-locale-rerun'), | ||||
|       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'), | ||||
|       showTimeStamps: el.getAttribute('data-locale-show-timestamps'), | ||||
|       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"> | ||||
|                 <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }} | ||||
|               </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> | ||||
|           </ul> | ||||
|         </div> | ||||
| @@ -528,6 +539,8 @@ export function initRepositoryActionView() { | ||||
| .job-artifacts-item { | ||||
|   margin: 5px 0; | ||||
|   padding: 6px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .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 octiconTable from '../../public/assets/img/svg/octicon-table.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 octiconX from '../../public/assets/img/svg/octicon-x.svg'; | ||||
| import octiconXCircleFill from '../../public/assets/img/svg/octicon-x-circle-fill.svg'; | ||||
| @@ -139,6 +140,7 @@ const svgs = { | ||||
|   'octicon-sync': octiconSync, | ||||
|   'octicon-table': octiconTable, | ||||
|   'octicon-tag': octiconTag, | ||||
|   'octicon-trash': octiconTrash, | ||||
|   'octicon-triangle-down': octiconTriangleDown, | ||||
|   'octicon-x': octiconX, | ||||
|   'octicon-x-circle-fill': octiconXCircleFill, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user