mirror of https://github.com/go-gitea/gitea.git
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
parent
0bc2a2836f
commit
16bdae53c8
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"}}"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')!;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue