feat(frontend): show build duration on job status and list
Display elapsed time while building and total duration from start to completion for success and failure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
52
frontend/src/lib/duration.ts
Normal file
52
frontend/src/lib/duration.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Build, BuildStatus } from "../api/client";
|
||||||
|
|
||||||
|
function parseDbDate(value: string): number {
|
||||||
|
const normalized = value.includes("T")
|
||||||
|
? value
|
||||||
|
: `${value.replace(" ", "T")}Z`;
|
||||||
|
const ms = Date.parse(normalized);
|
||||||
|
return Number.isNaN(ms) ? Date.now() : ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDurationMs(ms: number): string {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
if (totalSec < 60) {
|
||||||
|
return `${totalSec} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.floor(totalSec / 60);
|
||||||
|
const sec = totalSec % 60;
|
||||||
|
if (min < 60) {
|
||||||
|
return sec > 0 ? `${min} 分 ${sec} 秒` : `${min} 分`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = Math.floor(min / 60);
|
||||||
|
const remMin = min % 60;
|
||||||
|
return remMin > 0 ? `${hour} 小时 ${remMin} 分` : `${hour} 小时`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBuildFinished(status: BuildStatus): boolean {
|
||||||
|
return status === "completed" || status === "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBuildDuration(
|
||||||
|
build: Pick<Build, "createdAt" | "updatedAt" | "status">,
|
||||||
|
now = Date.now(),
|
||||||
|
): { ms: number; text: string; finished: boolean } {
|
||||||
|
const start = parseDbDate(build.createdAt);
|
||||||
|
const finished = isBuildFinished(build.status);
|
||||||
|
const end = finished ? parseDbDate(build.updatedAt) : now;
|
||||||
|
const ms = Math.max(0, end - start);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ms,
|
||||||
|
finished,
|
||||||
|
text: finished
|
||||||
|
? formatDurationMs(ms)
|
||||||
|
: `${formatDurationMs(ms)}(进行中)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string): string {
|
||||||
|
return new Date(parseDbDate(value)).toLocaleString();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { fetchBuilds, statusLabel, type Build } from "../api/client";
|
import { fetchBuilds, statusLabel, type Build } from "../api/client";
|
||||||
|
import { getBuildDuration, isBuildFinished } from "../lib/duration";
|
||||||
|
|
||||||
export default function BuildList() {
|
export default function BuildList() {
|
||||||
const [builds, setBuilds] = useState<Build[]>([]);
|
const [builds, setBuilds] = useState<Build[]>([]);
|
||||||
@@ -32,7 +33,10 @@ export default function BuildList() {
|
|||||||
{build.appName} ({build.appNameEn})
|
{build.appName} ({build.appNameEn})
|
||||||
</Link>
|
</Link>
|
||||||
<div className="meta-line">
|
<div className="meta-line">
|
||||||
v{build.appVersion} · {build.id} · {statusLabel(build.status)}{" "}
|
v{build.appVersion} · {statusLabel(build.status)}
|
||||||
|
{isBuildFinished(build.status)
|
||||||
|
? ` · 耗时 ${getBuildDuration(build).text}`
|
||||||
|
: null}{" "}
|
||||||
· {new Date(build.createdAt).toLocaleString()}
|
· {new Date(build.createdAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { fetchBuild, statusLabel, type Build } from "../api/client";
|
import { fetchBuild, statusLabel, type Build } from "../api/client";
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
getBuildDuration,
|
||||||
|
isBuildFinished,
|
||||||
|
} from "../lib/duration";
|
||||||
|
|
||||||
export default function JobStatus() {
|
export default function JobStatus() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [build, setBuild] = useState<Build | null>(null);
|
const [build, setBuild] = useState<Build | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -37,6 +43,14 @@ export default function JobStatus() {
|
|||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!build || isBuildFinished(build.status)) return;
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [build?.status, build?.id]);
|
||||||
|
|
||||||
|
const duration = build ? getBuildDuration(build, now) : null;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return <p className="error-text">缺少任务 ID</p>;
|
return <p className="error-text">缺少任务 ID</p>;
|
||||||
}
|
}
|
||||||
@@ -90,6 +104,22 @@ export default function JobStatus() {
|
|||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>开始时间</dt>
|
||||||
|
<dd>{formatDateTime(build.createdAt)}</dd>
|
||||||
|
</div>
|
||||||
|
{duration?.finished ? (
|
||||||
|
<div>
|
||||||
|
<dt>结束时间</dt>
|
||||||
|
<dd>{formatDateTime(build.updatedAt)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<dt>{duration?.finished ? "构建总耗时" : "已用时间"}</dt>
|
||||||
|
<dd>
|
||||||
|
<strong>{duration?.text ?? "—"}</strong>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{build.status === "completed" ? (
|
{build.status === "completed" ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user