From 16bdae53c812247a122976b62c3e6106f19be91b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 19 Apr 2026 09:37:50 +0200 Subject: [PATCH] Workflow Artifact Info Hover (#37100) Add expiry metadata to action artifacts in the run view and show it on hover. --------- Signed-off-by: Nicolas Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.6) Co-authored-by: wxiaoguang --- models/actions/artifact.go | 3 +- options/locale/locale_en-US.json | 1 + routers/web/devtest/mock_actions.go | 31 ++++++++++------- routers/web/repo/actions/view.go | 14 ++++---- templates/devtest/relative-time.tmpl | 1 + templates/repo/actions/view_component.tmpl | 1 + web_src/css/modules/divider.css | 8 +++++ .../js/components/ActionRunArtifacts.test.ts | 34 +++++++++++++++++++ web_src/js/components/ActionRunArtifacts.ts | 25 ++++++++++++++ web_src/js/components/RepoActionView.vue | 24 ++++++++----- web_src/js/features/repo-actions.ts | 1 + web_src/js/modules/gitea-actions.ts | 5 ++- web_src/js/modules/tippy.ts | 7 +++- web_src/js/utils.test.ts | 13 ++++++- web_src/js/utils.ts | 11 ++++++ web_src/js/utils/testhelper.ts | 8 +++++ 16 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 web_src/js/components/ActionRunArtifacts.test.ts create mode 100644 web_src/js/components/ActionRunArtifacts.ts diff --git a/models/actions/artifact.go b/models/actions/artifact.go index d61afb2aed4..ffadc79661a 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 343a672dc01..8efafd5c4b7 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 0fb2a358243..fe12dc3079c 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -67,6 +67,9 @@ func MockActionsView(ctx *context.Context) { func MockActionsRunsJobs(ctx *context.Context) { runID := ctx.PathParamInt64("run") + alignTime := func(v, unit int64) int64 { + return (v + unit) / unit * unit + } resp := &actions.ViewResponse{} resp.State.Run.RepoID = 12345 resp.State.Run.TitleHTML = `mock run title link` @@ -96,24 +99,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: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-b", - Size: 1024 * 1024, - Status: "completed", + Name: "artifact-b", + Size: 1024 * 1024, + Status: "completed", + ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600), }) 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: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) 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: 0, }) 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 f92df685fda..fb4dfa9603d 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 041ce49f09f..f4c664e26f3 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 405e9cfb4b1..2cc70e499ad 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/css/modules/divider.css b/web_src/css/modules/divider.css index a60b7d52cbe..32d03885d35 100644 --- a/web_src/css/modules/divider.css +++ b/web_src/css/modules/divider.css @@ -36,3 +36,11 @@ h4.divider { .divider.divider-text::after { margin-left: .75em; } + +.inline-divider { + display: inline-block; + border-left: 1px solid var(--color-secondary); + overflow: hidden; + width: 1px; + margin: 0 var(--gap-inline); +} diff --git a/web_src/js/components/ActionRunArtifacts.test.ts b/web_src/js/components/ActionRunArtifacts.test.ts new file mode 100644 index 00000000000..358510e8cd7 --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.test.ts @@ -0,0 +1,34 @@ +import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts'; +import {normalizeTestHtml} from '../utils/testhelper.ts'; + +describe('buildArtifactTooltipHtml', () => { + test('active artifact', () => { + const result = buildArtifactTooltipHtml({ + name: 'artifact.zip', + size: 1024 * 1024, + status: 'completed', + expiresUnix: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000, + }, 'Expires at %s (extra)'); + + expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(` +Expires at + + 2026-03-20T12:00:00.000Z + + (extra) + , + 1.0 MiB + +`)); + }); + + test('no expiry', () => { + const result = buildArtifactTooltipHtml({ + name: 'artifact.zip', + size: 512, + status: 'completed', + expiresUnix: 0, + }, 'Expires at %s'); + expect(normalizeTestHtml(result)).toBe(`512 B`); + }); +}); diff --git a/web_src/js/components/ActionRunArtifacts.ts b/web_src/js/components/ActionRunArtifacts.ts new file mode 100644 index 00000000000..ca8f5991620 --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.ts @@ -0,0 +1,25 @@ +import {html} from '../utils/html.ts'; +import {formatBytes} from '../utils.ts'; +import type {ActionsArtifact} from '../modules/gitea-actions.ts'; + +export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLocale: string): string { + const sizeText = formatBytes(artifact.size); + if (artifact.expiresUnix <= 0) { + return html`${sizeText}`; // use the same layout as below + } + + // split so the element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""] + const [prefix, suffix = ''] = expiresAtLocale.split('%s'); + const datetime = new Date(artifact.expiresUnix * 1000).toISOString(); + return html` + + ${prefix} + + ${datetime} + + ${suffix} + , + ${sizeText} + + `; +} diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index ee8b4880029..dbb5426ca78 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -5,7 +5,8 @@ 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 {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts'; defineOptions({ name: 'RepoActionView', @@ -20,7 +21,7 @@ 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 cancelRun() { POST(`${run.value.link}/cancel`); @@ -120,18 +121,24 @@ async function deleteArtifact(name: string) {
  • - + {{ artifact.name }} - {{ locale.artifactExpired }} + {{ locale.artifactExpired }}
@@ -251,6 +258,7 @@ async function deleteArtifact(name: string) { .left-list-header { font-size: 13px; + font-weight: var(--font-weight-semibold); color: var(--color-text-light-2); } diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index a3984e40cda..d8b13804ba5 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 96b31e4c949..bf7550a0329 100644 --- a/web_src/js/modules/gitea-actions.ts +++ b/web_src/js/modules/gitea-actions.ts @@ -1,5 +1,6 @@ // see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts" export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked'; +export type ActionsArtifactStatus = 'expired' | 'completed'; export type ActionsRun = { repoId: number, @@ -49,5 +50,7 @@ export type ActionsJob = { export type ActionsArtifact = { name: string; - status: string; + size: number; + status: ActionsArtifactStatus; + expiresUnix: number; }; diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index 22eb875c976..c2ca9ab51b0 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import type {Content, Instance, Placement, Props} from 'tippy.js'; import {html} from '../utils/html.ts'; +import {stripTags} from '../utils.ts'; type TippyOpts = { role?: string, @@ -85,6 +86,7 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc role: 'tooltip', theme: 'tooltip', hideOnClick, + allowHTML: target.getAttribute('data-tooltip-render') === 'html', placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start', followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false, ...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}), @@ -127,7 +129,10 @@ function attachLazyTooltip(el: HTMLElement): void { if (!el.hasAttribute('aria-label')) { const content = el.getAttribute('data-tooltip-content'); if (content) { - el.setAttribute('aria-label', content); + const isHtml = el.getAttribute('data-tooltip-render') === 'html'; + let ariaLabelValue = content; + if (isHtml) ariaLabelValue = stripTags(content).replace(/\s+/g, ' ').trim(); + el.setAttribute('aria-label', ariaLabelValue); } } } diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts index dfc498693e7..507334c0b41 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, translateMonth, translateDay, blobToDataURI, toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo, urlQueryEscape, @@ -122,6 +122,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 e812a7b978b..d802dd8e084 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -203,6 +203,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')!; diff --git a/web_src/js/utils/testhelper.ts b/web_src/js/utils/testhelper.ts index 9541ecdefb2..8da77d8134e 100644 --- a/web_src/js/utils/testhelper.ts +++ b/web_src/js/utils/testhelper.ts @@ -20,3 +20,11 @@ export function dedent(str: string) { return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim(); } + +export function normalizeTestHtml(s: string) { + const lines = s.replace(/>\s+\n<').trim().split('\n'); + for (let i = 0; i < lines.length; i++) { + lines[i] = lines[i].trim(); + } + return lines.join('\n'); +}