diff --git a/models/actions/artifact.go b/models/actions/artifact.go index d61afb2aed47b..ffadc79661a18 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -183,6 +183,7 @@ type ActionArtifactMeta struct { ArtifactName string FileSize int64 Status ArtifactStatus + ExpiredUnix timeutil.TimeStamp } // ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run @@ -191,7 +192,7 @@ func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*Act return arts, db.GetEngine(ctx).Table("action_artifact"). Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired). GroupBy("artifact_name"). - Select("artifact_name, sum(file_size) as file_size, max(status) as status"). + Select("artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix"). Find(&arts) } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 9d61e3f1d775f..d3bf94a1463a8 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -122,6 +122,7 @@ "unpin": "Unpin", "artifacts": "Artifacts", "expired": "Expired", + "artifact_expires_at": "Expires at %s", "confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?", "archived": "Archived", "concept_system_global": "Global", diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 0fb2a358243c0..691850bb402fd 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -96,24 +96,28 @@ func MockActionsRunsJobs(ctx *context.Context) { }, } resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-a", - Size: 100 * 1024, - Status: "expired", + Name: "artifact-a", + Size: 100 * 1024, + Status: "expired", + ExpiresUnix: time.Now().Add(-24 * time.Hour).Unix(), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-b", - Size: 1024 * 1024, - Status: "completed", + Name: "artifact-b", + Size: 1024 * 1024, + Status: "completed", + ExpiresUnix: time.Now().Add(24 * time.Hour).Unix(), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", - Size: 100 * 1024, - Status: "expired", + Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 100 * 1024, + Status: "expired", + ExpiresUnix: time.Now().Add(-24 * time.Hour).Unix(), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", - Size: 1024 * 1024, - Status: "completed", + Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 1024 * 1024, + Status: "completed", + ExpiresUnix: time.Now().Add(24 * time.Hour).Unix(), }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index f92df685fda13..fb4dfa9603df2 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -248,9 +248,10 @@ type ViewRequest struct { } type ArtifactsViewItem struct { - Name string `json:"name"` - Size int64 `json:"size"` - Status string `json:"status"` + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` + ExpiresUnix int64 `json:"expiresUnix"` } type ViewResponse struct { @@ -344,9 +345,10 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact } for _, art := range artifacts { artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{ - Name: art.ArtifactName, - Size: art.FileSize, - Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), + Name: art.ArtifactName, + Size: art.FileSize, + Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), + ExpiresUnix: int64(art.ExpiredUnix), }) } return artifactsViewItems, nil diff --git a/templates/devtest/relative-time.tmpl b/templates/devtest/relative-time.tmpl index 041ce49f09f3d..f4c664e26f313 100644 --- a/templates/devtest/relative-time.tmpl +++ b/templates/devtest/relative-time.tmpl @@ -38,6 +38,7 @@
numeric:
weekday:
with time:
+
minutes:

Threshold

diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 405e9cfb4b111..2cc70e499ad36 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -28,6 +28,7 @@ data-locale-status-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}" data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}" data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}" + data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}" 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"}}" diff --git a/web_src/js/components/ActionRunArtifacts.test.ts b/web_src/js/components/ActionRunArtifacts.test.ts new file mode 100644 index 0000000000000..9a03b18584adb --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.test.ts @@ -0,0 +1,33 @@ +import {createArtifactTooltipElement} from './ActionRunArtifacts.ts'; + +test('createArtifactTooltipElement for active artifact', () => { + const el = createArtifactTooltipElement({ + name: 'artifact.zip', + size: 1024 * 1024, + status: 'completed', + expiresUnix: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000, + }, 'Expires at %s'); + + const rt = el.querySelector('relative-time')!; + expect(rt).not.toBeNull(); + expect(rt.getAttribute('datetime')).toBe('2026-03-20T12:00:00.000Z'); + expect(rt.getAttribute('threshold')).toBe('P0Y'); + expect(rt.getAttribute('month')).toBe('short'); + expect(rt.getAttribute('hour')).toBe('numeric'); + expect(rt.getAttribute('minute')).toBe('2-digit'); + expect(el.textContent).toContain('Expires at'); + expect(el.textContent).toContain('1.0 MiB'); + expect(el.querySelector('.artifact-size')).not.toBeNull(); +}); + +test('createArtifactTooltipElement with no expiry', () => { + const el = createArtifactTooltipElement({ + name: 'artifact.zip', + size: 512, + status: 'completed', + expiresUnix: 0, + }, 'Expires at %s'); + + expect(el.querySelector('relative-time')).toBeNull(); + expect(el.textContent).toBe('512 B'); +}); diff --git a/web_src/js/components/ActionRunArtifacts.ts b/web_src/js/components/ActionRunArtifacts.ts new file mode 100644 index 0000000000000..ba50afebe391b --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.ts @@ -0,0 +1,20 @@ +import {createElementFromAttrs} from '../utils/dom.ts'; +import {formatBytes} from '../utils.ts'; +import type {ActionsArtifact} from '../modules/gitea-actions.ts'; + +export function createArtifactTooltipElement(artifact: ActionsArtifact, expiresAtLocale: string): HTMLElement { + const sizeText = formatBytes(artifact.size); + + if (artifact.expiresUnix <= 0) { + return createElementFromAttrs('span', null, sizeText); + } + + const datetime = new Date(artifact.expiresUnix * 1000).toISOString(); + const parts = expiresAtLocale.split('%s'); + const relativeTime = createElementFromAttrs('relative-time', { + datetime, threshold: 'P0Y', prefix: '', weekday: '', + year: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', + }); + const sizeSpan = createElementFromAttrs('span', {class: 'artifact-size tw-border-l tw-border-current tw-ml-2 tw-pl-2'}, sizeText); + return createElementFromAttrs('span', null, parts[0] ?? '', relativeTime, parts[1] ?? '', sizeSpan); +} diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index ee8b4880029a9..bdba67e81e33a 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -5,7 +5,9 @@ import {toRefs} from 'vue'; import {POST, DELETE} from '../modules/fetch.ts'; import ActionRunSummaryView from './ActionRunSummaryView.vue'; import ActionRunJobView from './ActionRunJobView.vue'; -import {createActionRunViewStore} from "./ActionRunView.ts"; +import {createActionRunViewStore} from './ActionRunView.ts'; +import {createArtifactTooltipElement} from './ActionRunArtifacts.ts'; +import {createTippy} from '../modules/tippy.ts'; defineOptions({ name: 'RepoActionView', @@ -20,7 +22,16 @@ const props = defineProps<{ const locale = props.locale; const store = createActionRunViewStore(props.actionsUrl, props.runId); -const {currentRun: run , runArtifacts: artifacts} = toRefs(store.viewData); +const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData); + +function initSidebarTooltip(el: HTMLElement | null, content: string | HTMLElement) { + if (!el) return; + if (el._tippy) { + el._tippy.setContent(content); + } else { + createTippy(el, {content, role: 'tooltip', theme: 'tooltip', placement: 'top-end', arrow: false, offset: [0, 2]}); + } +} function cancelRun() { POST(`${run.value.link}/cancel`); @@ -118,20 +129,20 @@ async function deleteArtifact(name: string) {
{{ locale.artifactsTitle }} ({{ artifacts.length }})
@@ -251,6 +262,7 @@ async function deleteArtifact(name: string) { .left-list-header { font-size: 13px; + font-weight: var(--font-weight-semibold); color: var(--color-text-light-2); } @@ -259,6 +271,16 @@ async function deleteArtifact(name: string) { padding-left: 10px; } +.action-view-left .ui.relaxed.list > .item { + padding-top: 0; + padding-bottom: 0; +} + +.action-view-left .ui.relaxed.list > .item > :first-child { + padding-top: 0.42857143em; + padding-bottom: 0.42857143em; +} + .job-brief-item { padding: 6px 10px; border-radius: var(--border-radius); diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index a3984e40cda5d..d8b13804ba540 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -33,6 +33,7 @@ export function initRepositoryActionView() { artifactsTitle: el.getAttribute('data-locale-artifacts-title'), areYouSure: el.getAttribute('data-locale-are-you-sure'), artifactExpired: el.getAttribute('data-locale-artifact-expired'), + artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), showTimeStamps: el.getAttribute('data-locale-show-timestamps'), showLogSeconds: el.getAttribute('data-locale-show-log-seconds'), diff --git a/web_src/js/modules/gitea-actions.ts b/web_src/js/modules/gitea-actions.ts index 96b31e4c9496e..771e9bb500d07 100644 --- a/web_src/js/modules/gitea-actions.ts +++ b/web_src/js/modules/gitea-actions.ts @@ -49,5 +49,7 @@ export type ActionsJob = { export type ActionsArtifact = { name: string; + size: number; status: string; + expiresUnix: number; }; diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts index f041a2cecad98..8889e1b5c1e7d 100644 --- a/web_src/js/utils.test.ts +++ b/web_src/js/utils.test.ts @@ -1,5 +1,5 @@ import { - dirname, basename, extname, isObject, stripTags, parseIssueHref, + dirname, basename, extname, formatBytes, isObject, stripTags, parseIssueHref, parseUrl, translateMonth, translateDay, blobToDataURI, toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo, urlQueryEscape, @@ -134,6 +134,17 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { expect(new Uint8Array(decodeURLEncodedBase64('YQ=='))).toEqual(uint8array('a')); }); +test('formatBytes', () => { + expect(formatBytes(-1)).toBe('0 B'); + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(1024)).toBe('1.0 KiB'); + expect(formatBytes(1536)).toBe('1.5 KiB'); + expect(formatBytes(10 * 1024)).toBe('10 KiB'); + expect(formatBytes(1024 * 1024)).toBe('1.0 MiB'); + expect(formatBytes(1024 * 1024 * 1024)).toBe('1.0 GiB'); +}); + test('file detection', () => { for (const name of ['a.avif', 'a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) { expect(isImageFile({name})).toBeTruthy(); diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index 1ea201ab82694..ba35a714a16ab 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -208,6 +208,17 @@ export function isVideoFile({name, type}: {name?: string, type?: string}): boole return Boolean(/\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/')); } +const byteUnits = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']; + +export function formatBytes(num: number, precision = 2): string { + if (!Number.isFinite(num) || num < 0) return `0 ${byteUnits[0]}`; + if (num < 1024) return `${num} ${byteUnits[0]}`; + const exp = Math.min(Math.floor(Math.log2(num) / 10), byteUnits.length - 1); + const value = num / (1024 ** exp); + const digits = Math.max(0, precision - 1 - Math.floor(Math.log10(value))); + return `${value.toFixed(digits)} ${byteUnits[exp]}`; +} + export function toggleFullScreen(fullScreenEl: HTMLElement, isFullScreen: boolean, sourceParentSelector?: string): void { // hide other elements const headerEl = document.querySelector('#navbar')!;