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