Workflow Artifact Info Hover (#37100)

Add expiry metadata to action artifacts in the run view and show it on hover.

---------

Signed-off-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
pull/37284/head^2
Nicolas 2026-04-19 09:37:50 +02:00 committed by GitHub
parent 0bc2a2836f
commit 16bdae53c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 157 additions and 30 deletions

View File

@ -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)
}

View File

@ -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",

View File

@ -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 <a href="/">link</a>`
@ -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{

View File

@ -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

View File

@ -38,6 +38,7 @@
<div>numeric: <relative-time datetime="2024-03-11" threshold="P0Y" prefix="" weekday="" year="" day="numeric" month="numeric"></relative-time></div>
<div>weekday: <relative-time datetime="2024-03-11" threshold="P0Y" prefix="" weekday="long" year="" month="numeric"></relative-time></div>
<div>with time: <relative-time datetime="2024-03-11T19:00:00-05:00" threshold="P0Y" prefix="" weekday="long" year="" month="numeric"></relative-time></div>
<div>minutes: <relative-time datetime="2024-03-11T19:30:45-05:00" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit"></relative-time></div>
</div>
<div>
<h2>Threshold</h2>

View File

@ -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"}}"

View File

@ -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);
}

View File

@ -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(`<span class="flex-text-inline">
<span>Expires at </span>
<relative-time datetime="2026-03-20T12:00:00.000Z" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
2026-03-20T12:00:00.000Z
</relative-time>
<span> (extra)</span>
<span class="inline-divider">,</span>
<span>1.0 MiB</span>
</span>
`));
});
test('no expiry', () => {
const result = buildArtifactTooltipHtml({
name: 'artifact.zip',
size: 512,
status: 'completed',
expiresUnix: 0,
}, 'Expires at %s');
expect(normalizeTestHtml(result)).toBe(`<span class="flex-text-inline">512 B</span>`);
});
});

View File

@ -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`<span class="flex-text-inline">${sizeText}</span>`; // use the same layout as below
}
// split so the <relative-time> 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`
<span class="flex-text-inline">
<span>${prefix}</span>
<relative-time datetime="${datetime}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${datetime}
</relative-time>
<span>${suffix}</span>
<span class="inline-divider">,</span>
<span>${sizeText}</span>
</span>
`;
}

View File

@ -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) {
<ul class="ui relaxed list flex-items-block">
<li class="item" v-for="artifact in artifacts" :key="artifact.name">
<template v-if="artifact.status !== 'expired'">
<a class="tw-flex-1 flex-text-block" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
<SvgIcon name="octicon-file" class="tw-text-text"/>
<a
class="tw-flex-1 flex-text-block muted" target="_blank"
:href="run.link+'/artifacts/'+encodeURIComponent(artifact.name)"
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
data-tooltip-render="html"
data-tooltip-placement="top-end"
>
<SvgIcon name="octicon-file" class="tw-text-text-light"/>
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
</a>
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
<SvgIcon name="octicon-trash" class="tw-text-text"/>
<a v-if="run.canDeleteArtifact" class="muted" @click="deleteArtifact(artifact.name)">
<SvgIcon name="octicon-trash"/>
</a>
</template>
<span v-else class="flex-text-block tw-flex-1 tw-text-grey-light">
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
<SvgIcon name="octicon-file"/>
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
<span class="ui label tw-text-grey-light tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
</span>
</li>
</ul>
@ -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);
}

View File

@ -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'),

View File

@ -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;
};

View File

@ -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);
}
}
}

View File

@ -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();

View File

@ -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')!;

View File

@ -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+</g, '>\n<').trim().split('\n');
for (let i = 0; i < lines.length; i++) {
lines[i] = lines[i].trim();
}
return lines.join('\n');
}