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 | 	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 | ||||||
|  | } | ||||||
|   | |||||||
| @@ -123,6 +123,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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -57,15 +57,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"` | ||||||
| @@ -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.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() | ||||||
| @@ -535,6 +537,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") | ||||||
| @@ -562,6 +587,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) | ||||||
|   | |||||||
| @@ -1368,6 +1368,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) | ||||||
| 			}) | 			}) | ||||||
| 		}, reqRepoActionsReader, actions.MustEnableActions) | 		}, reqRepoActionsReader, actions.MustEnableActions) | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user