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