mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 09:50:55 +08:00
Refactor web UI: feature-first structure, tokenized styles, modern blue theme.
Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
(2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.
Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
without tree indent, soft selected state instead of left accent bar.
Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b45d6abe3
commit
0d894efe5e
196 changed files with 26947 additions and 28091 deletions
32
web/src/features/audit/AuditLayout.tsx
Normal file
32
web/src/features/audit/AuditLayout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { AUDIT_NAV } from "../../config/auditNav";
|
||||
import { useI18n } from "../../i18n";
|
||||
|
||||
export function AuditLayout() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="network-shell audit-shell">
|
||||
<aside className="network-nav" aria-label={t("audit.title")}>
|
||||
<nav className="network-nav__scroll">
|
||||
<ul className="network-nav__list network-nav__list--flat">
|
||||
{AUDIT_NAV.map((item) => (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={({ isActive }) => `network-nav__link${isActive ? " is-active" : ""}`}
|
||||
end={item.path === "/audit/tasks"}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="network-main">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
web/src/features/audit/AuditPage.tsx
Normal file
332
web/src/features/audit/AuditPage.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { apiGet } from "../../services/api";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
type AuditItem = {
|
||||
id: string;
|
||||
ts: string | null;
|
||||
actor_username: string;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
client_ip: string;
|
||||
detail: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Quick filters map to action substring and/or exclude_noise flag. */
|
||||
type QuickFilter = "business" | "webcrt." | "ne.exec" | "auth." | "http." | "all";
|
||||
|
||||
function statusClass(code: number): string {
|
||||
if (code >= 500) return "pt-list-status--failed";
|
||||
if (code >= 400) return "pt-list-status--warning";
|
||||
if (code >= 200 && code < 300) return "pt-list-status--ok";
|
||||
return "pt-list-status--other";
|
||||
}
|
||||
|
||||
function detailStr(detail: Record<string, unknown>, key: string): string {
|
||||
const v = detail?.[key];
|
||||
if (v == null) return "";
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
function deviceLabel(detail: Record<string, unknown>): string {
|
||||
const name = detailStr(detail, "ne_name");
|
||||
const ip = detailStr(detail, "ne_ip");
|
||||
if (name && ip) return `${name} (${ip})`;
|
||||
return name || ip || detailStr(detail, "ne_id") || "";
|
||||
}
|
||||
|
||||
export function auditSummary(
|
||||
action: string,
|
||||
detail: Record<string, unknown>,
|
||||
t: (key: string, vars?: Record<string, string>) => string,
|
||||
row?: Pick<AuditItem, "method" | "path">,
|
||||
): string {
|
||||
const d = detail || {};
|
||||
const device = deviceLabel(d);
|
||||
if (action === "webcrt.session_connecting") {
|
||||
return t("audit.summary.connecting", { device: device || t("common.empty") });
|
||||
}
|
||||
if (action === "webcrt.session_created") {
|
||||
return t("audit.summary.loginOk", { device: device || t("common.empty") });
|
||||
}
|
||||
if (action === "webcrt.session_open_failed") {
|
||||
const err = detailStr(d, "error") || t("common.empty");
|
||||
return t("audit.summary.loginFail", { device: device || t("common.empty"), error: err.slice(0, 80) });
|
||||
}
|
||||
if (action === "webcrt.session_closed") {
|
||||
const reason = detailStr(d, "reason");
|
||||
return reason
|
||||
? t("audit.summary.logoutReason", { device: device || t("common.empty"), reason })
|
||||
: t("audit.summary.logout", { device: device || t("common.empty") });
|
||||
}
|
||||
if (action === "webcrt.command") {
|
||||
const cmd = detailStr(d, "command") || t("common.empty");
|
||||
const redacted = Boolean(d.redacted);
|
||||
return t("audit.summary.command", {
|
||||
device: device || t("common.empty"),
|
||||
command: redacted ? "***" : cmd,
|
||||
});
|
||||
}
|
||||
if (action === "ne.exec") {
|
||||
const cmds = Array.isArray(d.commands) ? d.commands.map(String).filter(Boolean) : [];
|
||||
return t("audit.summary.neExec", {
|
||||
device: device || t("common.empty"),
|
||||
commands: cmds.slice(0, 3).join("; ") || t("common.empty"),
|
||||
});
|
||||
}
|
||||
if (action === "ne.exec_batch") {
|
||||
const neIds = Array.isArray(d.ne_ids) ? (d.ne_ids as unknown[]) : [];
|
||||
const targetCount =
|
||||
d.target_count != null && d.target_count !== ""
|
||||
? Number(d.target_count)
|
||||
: neIds.length;
|
||||
return t("audit.summary.neExecBatch", {
|
||||
n: String(Number.isFinite(targetCount) ? targetCount : 0),
|
||||
ok: String(d.ok_count != null ? Number(d.ok_count) : 0),
|
||||
fail: String(d.fail_count != null ? Number(d.fail_count) : 0),
|
||||
});
|
||||
}
|
||||
if (action.startsWith("auth.")) {
|
||||
if (action === "auth.unauthorized") {
|
||||
const method = row?.method || "GET";
|
||||
const path = row?.path || "";
|
||||
return path ? `${method} ${path}` : "unauthorized";
|
||||
}
|
||||
return action.replace(/^auth\./, "");
|
||||
}
|
||||
if (action.startsWith("http.") || action.startsWith("ume.")) {
|
||||
const method = row?.method || action.replace(/^http\./, "").toUpperCase();
|
||||
const path = row?.path || "";
|
||||
return path ? `${method} ${path}` : method;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function quickToQuery(quick: QuickFilter): { action: string; excludeNoise: boolean } {
|
||||
if (quick === "business") return { action: "", excludeNoise: true };
|
||||
if (quick === "all") return { action: "", excludeNoise: false };
|
||||
if (quick === "http.") return { action: "http.", excludeNoise: false };
|
||||
return { action: quick, excludeNoise: true };
|
||||
}
|
||||
|
||||
export function AuditPage() {
|
||||
const { t } = useI18n();
|
||||
const { ready, isAdmin } = useAuth();
|
||||
const [page, setPage] = useState(1);
|
||||
const [username, setUsername] = useState("");
|
||||
const [action, setAction] = useState("");
|
||||
const [quick, setQuick] = useState<QuickFilter>("business");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const derived = quickToQuery(quick);
|
||||
const effectiveAction = action.trim() || derived.action;
|
||||
const excludeNoise = action.trim() ? false : derived.excludeNoise;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["auditLogs", page, username, effectiveAction, excludeNoise],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(page));
|
||||
p.set("page_size", "10");
|
||||
p.set("exclude_noise", excludeNoise ? "true" : "false");
|
||||
if (username.trim()) p.set("username", username.trim());
|
||||
if (effectiveAction) p.set("action", effectiveAction);
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: AuditItem[] }>(
|
||||
`/v1/audit-logs?${p.toString()}`,
|
||||
);
|
||||
},
|
||||
enabled: ready,
|
||||
});
|
||||
|
||||
const items = useMemo(() => query.data?.items || [], [query.data]);
|
||||
const total = query.data?.total || 0;
|
||||
const pages = Math.max(1, Math.ceil(total / 10));
|
||||
const hasFilters = Boolean(username.trim() || action.trim() || quick !== "business");
|
||||
|
||||
const setQuickFilter = (next: QuickFilter) => {
|
||||
setPage(1);
|
||||
setQuick(next);
|
||||
if (next !== "all" && next !== "business") setAction("");
|
||||
else setAction("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("audit.logsTitle")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline audit-quick-filters">
|
||||
{(
|
||||
[
|
||||
["business", "audit.filterBusiness"],
|
||||
["webcrt.", "audit.filterWebcrt"],
|
||||
["ne.exec", "audit.filterNeExec"],
|
||||
["auth.", "audit.filterAuth"],
|
||||
["http.", "audit.filterHttp"],
|
||||
["all", "audit.filterAll"],
|
||||
] as const
|
||||
).map(([value, labelKey]) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={quick === value && !action.trim() ? "primary" : "secondary"}
|
||||
onPress={() => setQuickFilter(value)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="filter-inline">
|
||||
{isAdmin ? (
|
||||
<Input
|
||||
placeholder={t("auth.filterUsername")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setUsername(e.target.value);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Input
|
||||
placeholder={t("auth.filterAction")}
|
||||
value={action}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setAction(e.target.value);
|
||||
if (e.target.value.trim()) setQuick("all");
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onPress={() => void query.refetch()} isDisabled={query.isFetching}>
|
||||
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasFilters}
|
||||
onPress={() => {
|
||||
setUsername("");
|
||||
setAction("");
|
||||
setQuick("business");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
||||
{!items.length && !query.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("common.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.colTime")}</th>
|
||||
<th>{t("auth.colUser")}</th>
|
||||
<th>{t("auth.colAction")}</th>
|
||||
<th>{t("audit.colSummary")}</th>
|
||||
<th>{t("auth.colStatus")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
<th>{t("audit.colDetail")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => {
|
||||
const open = expanded === row.id;
|
||||
const summary = auditSummary(row.action, row.detail || {}, t, row);
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<tr>
|
||||
<td className="pt-list-time">
|
||||
{row.ts ? formatSystemTime(row.ts) : t("common.empty")}
|
||||
</td>
|
||||
<td className="pt-list-task-name">{row.actor_username || t("common.empty")}</td>
|
||||
<td className="pt-list-num">{row.action}</td>
|
||||
<td className="audit-summary-cell">{summary || row.path || t("common.empty")}</td>
|
||||
<td>
|
||||
{row.status_code ? (
|
||||
<span className={`pt-list-status ${statusClass(row.status_code)}`}>
|
||||
{row.status_code}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="linkish"
|
||||
onPress={() => setExpanded(open ? null : row.id)}
|
||||
>
|
||||
{open ? t("audit.hideDetail") : t("audit.showDetail")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{open ? (
|
||||
<tr className="audit-detail-row">
|
||||
<td colSpan={7}>
|
||||
<pre className="audit-detail-pre">{JSON.stringify(row.detail || {}, null, 2)}</pre>
|
||||
{row.method || row.path ? (
|
||||
<p className="muted audit-detail-meta">
|
||||
{row.method} {row.path}
|
||||
</p>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(total),
|
||||
page: String(page),
|
||||
pages: String(pages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={page <= 1}
|
||||
onPress={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={page >= pages}
|
||||
onPress={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
web/src/features/audit/TaskOverviewPage.tsx
Normal file
194
web/src/features/audit/TaskOverviewPage.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { fetchOpsTasks, type OpsTaskItem } from "../../services/api";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
function statusTone(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running";
|
||||
if (s === "ready" || s === "idle") return "ok";
|
||||
if (s === "paused" || s === "pending" || s === "detached") return "paused";
|
||||
if (s === "failed" || s === "error") return "stopped";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function kindLabel(kind: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
|
||||
const key = `audit.tasks.kind.${kind}`;
|
||||
const tr = t(key);
|
||||
return tr === key ? kind : tr;
|
||||
}
|
||||
|
||||
function statusLabel(status: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
|
||||
const key = `audit.tasks.status.${status}`;
|
||||
const tr = t(key);
|
||||
return tr === key ? status : tr;
|
||||
}
|
||||
|
||||
function triggerLabel(trigger: string, t: (k: string, vars?: Record<string, string | number>) => string): string {
|
||||
const raw = String(trigger || "").trim();
|
||||
if (!raw || raw === "—") return "—";
|
||||
const key = `audit.tasks.trigger.${raw}`;
|
||||
const tr = t(key);
|
||||
return tr === key ? raw : tr;
|
||||
}
|
||||
|
||||
function runtimeTaskSubject(
|
||||
taskId: string,
|
||||
t: (k: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const key = `ume.tasks.runtimeTask.${taskId}`;
|
||||
const label = t(key);
|
||||
return label === key ? taskId : label;
|
||||
}
|
||||
|
||||
function taskTitle(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string {
|
||||
const kind = kindLabel(row.kind, t);
|
||||
const subjectRaw = String(row.title || row.id || "").trim();
|
||||
const subject =
|
||||
row.kind === "ume_runtime" && subjectRaw ? runtimeTaskSubject(subjectRaw, t) : subjectRaw;
|
||||
if (!subject) return kind;
|
||||
// Avoid "Kind · Kind · x" if an old backend still prefixed the localized kind.
|
||||
if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) {
|
||||
return subject;
|
||||
}
|
||||
return `${kind} · ${subject}`;
|
||||
}
|
||||
|
||||
function progressLabel(row: OpsTaskItem, t: (k: string, vars?: Record<string, string | number>) => string): string {
|
||||
const base = String(row.progress || "").trim();
|
||||
const inflight = Number(row.inflight || 0);
|
||||
if (inflight > 0) {
|
||||
const extra = t("audit.tasks.inflight", { n: inflight });
|
||||
return base ? `${base} · ${extra}` : extra;
|
||||
}
|
||||
return base || "—";
|
||||
}
|
||||
|
||||
export function TaskOverviewPage() {
|
||||
const { t } = useI18n();
|
||||
const query = useQuery({
|
||||
queryKey: ["opsTasks"],
|
||||
queryFn: fetchOpsTasks,
|
||||
refetchInterval: POLL_MS,
|
||||
staleTime: 1500,
|
||||
});
|
||||
|
||||
const data = query.data;
|
||||
const items = useMemo(() => data?.items || [], [data]);
|
||||
const byKind = data?.by_kind || {};
|
||||
const byStatus = data?.by_status || {};
|
||||
|
||||
return (
|
||||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("audit.tasks.title")}</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => void query.refetch()}
|
||||
isDisabled={query.isFetching}
|
||||
>
|
||||
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-list-kpis">
|
||||
<div className="pt-list-kpi pt-list-kpi--live">
|
||||
<div className="pt-list-kpi__label">{t("audit.tasks.kpiActive")}</div>
|
||||
<div className="pt-list-kpi__value">{data?.active ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("audit.tasks.kpiTotal")}</div>
|
||||
<div className="pt-list-kpi__value">{data?.total ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("audit.tasks.kpiKinds")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{Object.keys(byKind).length
|
||||
? Object.entries(byKind)
|
||||
.map(([k, n]) => `${kindLabel(k, t)} ${n}`)
|
||||
.join(" · ")
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("audit.tasks.kpiStatuses")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{Object.keys(byStatus).length
|
||||
? Object.entries(byStatus)
|
||||
.map(([k, n]) => `${statusLabel(k, t)} ${n}`)
|
||||
.join(" · ")
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
||||
{!items.length && !query.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("audit.tasks.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("audit.tasks.colKind")}</th>
|
||||
<th>{t("audit.tasks.colTitle")}</th>
|
||||
<th>{t("audit.tasks.colStatus")}</th>
|
||||
<th>{t("audit.tasks.colActor")}</th>
|
||||
<th>{t("audit.tasks.colTrigger")}</th>
|
||||
<th>{t("audit.tasks.colProgress")}</th>
|
||||
<th>{t("audit.tasks.colStarted")}</th>
|
||||
<th>{t("audit.tasks.colUpdated")}</th>
|
||||
<th>{t("audit.tasks.colLink")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={`${row.kind}:${row.id}`} title={row.detail || undefined}>
|
||||
<td>{kindLabel(row.kind, t)}</td>
|
||||
<td>
|
||||
<div className="pt-list-task-name">{taskTitle(row, t)}</div>
|
||||
{row.detail ? (
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
{row.detail}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pt-list-status pt-list-status--${statusTone(row.status)}`}>
|
||||
{statusLabel(row.status, t)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{row.actor || "—"}</td>
|
||||
<td>{triggerLabel(row.trigger, t)}</td>
|
||||
<td className="pt-list-num">{progressLabel(row, t)}</td>
|
||||
<td className="pt-list-time">{formatSystemTime(row.started_at) || "—"}</td>
|
||||
<td className="pt-list-time">{formatSystemTime(row.updated_at) || "—"}</td>
|
||||
<td>
|
||||
{row.href ? (
|
||||
<Link to={row.href} className="link-btn">
|
||||
{t("audit.tasks.open")}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue