feat(notifications): finalize package and run results

This commit is contained in:
Sucukdeluxe
2026-08-22 05:08:48 +02:00
parent c7e48891bb
commit 03da04b629
9 changed files with 1614 additions and 416 deletions
+6 -1
View File
@@ -182,8 +182,9 @@ export class AppController {
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
protectEmptyClobber: loadResult.status === "empty-unreadable",
enqueueNotification: (event) => this.notificationOutbox.enqueue(event),
onHistoryEntry: (entry: HistoryEntry) => {
this.recordHistoryEntry(entry);
}
@@ -1295,6 +1296,10 @@ export class AppController {
stopDebugServer();
abortActiveUpdateDownload();
cancelPendingAsyncSaves();
const notificationFlush = this.manager.flushNotificationsForShutdown?.();
if (notificationFlush) {
await notificationFlush;
}
await this.notificationOutbox.drainForShutdown(3000).catch((error) => {
logger.warn(`Notification-Outbox konnte beim Beenden nicht geleert werden: ${String(error)}`);
});
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
import { createHash } from "node:crypto";
import type {
DebridProvider,
HistoryEntry,
PackageResult,
PackageResultStatus
} from "../shared/types";
import type {
NotificationEvent,
NotificationEventType,
NotificationPriority
} from "./notification-outbox";
export interface PackageResultEnvelope {
generation: number;
result: PackageResult;
}
export interface RunResult {
id: string;
stopped: boolean;
startedAt: number;
completedAt: number;
totalDurationSeconds: number;
totalPackages: number;
completedPackages: number;
partialPackages: number;
failedPackages: number;
cancelledPackages: number;
successfulFiles: number;
failedFiles: number;
cancelledFiles: number;
totalBytes: number;
downloadedBytes: number;
averageDownloadSpeedBps: number;
downloadDurationSeconds: number;
extractionDurationSeconds: number;
remuxDurationSeconds: number;
postProcessDurationSeconds: number;
extractionFailures: number;
remuxFailures: number;
downloadFailures: number;
offlineFailures: number;
}
export interface RunResultInput {
id: string;
stopped: boolean;
startedAt: number;
completedAt: number;
packages: readonly PackageResult[];
totalPackages?: number;
successfulFiles?: number;
failedFiles?: number;
cancelledFiles?: number;
}
export interface HistoryEntryContext {
generation: number;
outputDir: string;
urls: string[];
provider: DebridProvider | null;
}
const SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
const IMPORTANT_TTL_MS = 24 * 60 * 60 * 1000;
const DIGEST_PACKAGE_LIMIT = 20;
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
function finiteNonNegative(value: unknown): number {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
function formatBytes(bytes: number): string {
let value = finiteNonNegative(bytes);
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${numberFormatter.format(value)} ${units[index]}`;
}
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.floor(finiteNonNegative(seconds)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const remainder = total % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`
: `${minutes}:${String(remainder).padStart(2, "0")}`;
}
function statusLabel(status: PackageResultStatus): string {
if (status === "completed") return "Abgeschlossen";
if (status === "partial") return "Teilweise abgeschlossen";
if (status === "failed") return "Fehlgeschlagen";
return "Abgebrochen";
}
function failurePhaseLabel(result: PackageResult): string {
if (result.failurePhase === "download") return "Download";
if (result.failurePhase === "extract") return "Entpacken";
if (result.failurePhase === "remux") return "Remux";
if (result.failurePhase === "cleanup") return "Aufräumen";
return "—";
}
function event(
id: string,
type: NotificationEventType,
priority: NotificationPriority,
createdAt: number,
title: string,
description: string,
color: number,
fields: NotificationEvent["payload"]["fields"]
): NotificationEvent {
return {
id,
type,
priority,
createdAt,
expiresAt: createdAt + (priority === "success" ? SUCCESS_TTL_MS : IMPORTANT_TTL_MS),
attempts: 0,
nextAttemptAt: createdAt,
payload: { title, description, color, fields }
};
}
function packageEventType(status: PackageResultStatus): NotificationEventType {
if (status === "completed") return "package_completed";
if (status === "partial") return "package_partial";
return "package_failed";
}
export function buildPackageNotificationEvent(
envelope: PackageResultEnvelope,
createdAt: number
): NotificationEvent {
const { generation, result } = envelope;
const type = packageEventType(result.status);
const priority: NotificationPriority = result.status === "completed" ? "success" : "error";
const title = result.status === "completed"
? "✅ Paket fertig"
: result.status === "partial"
? "⚠️ Paket teilweise fertig"
: result.status === "cancelled"
? "⏹️ Paket abgebrochen"
: "❌ Paket fehlgeschlagen";
const fields = [
{ name: "Paket", value: result.name || "—", inline: false },
{ name: "Ergebnis", value: statusLabel(result.status), inline: true },
{ name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false },
{ name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true },
{ name: "Zeiten", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false },
{ name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true },
{ name: "Archive", value: `${result.archiveCount} Gruppen · ${result.partCount} Parts · ${result.outputCount} Ausgaben`, inline: true }
];
if (result.failurePhase) {
fields.push({
name: "Fehler",
value: `${failurePhaseLabel(result)}${result.errorCategory ? ` · ${result.errorCategory.slice(0, 256)}` : ""}`,
inline: false
});
}
return event(
`package:${result.packageId}:${generation}:${type}`,
type,
priority,
createdAt,
title,
result.name,
priority === "success" ? 0x2ecc71 : 0xe74c3c,
fields
);
}
export function buildPackageDigestEvents(
envelopes: readonly PackageResultEnvelope[],
createdAt: number
): NotificationEvent[] {
const sorted = [...envelopes].sort((left, right) => {
const byCompleted = left.result.completedAt - right.result.completedAt;
if (byCompleted !== 0) return byCompleted;
const byPackage = left.result.packageId.localeCompare(right.result.packageId);
return byPackage !== 0 ? byPackage : left.generation - right.generation;
});
const digestKey = createHash("sha256")
.update(sorted.map((entry) => `${entry.result.packageId}:${entry.generation}`).join("|"))
.digest("hex")
.slice(0, 16);
const events: NotificationEvent[] = [];
for (let offset = 0; offset < sorted.length; offset += DIGEST_PACKAGE_LIMIT) {
const chunk = sorted.slice(offset, offset + DIGEST_PACKAGE_LIMIT);
const page = Math.floor(offset / DIGEST_PACKAGE_LIMIT) + 1;
const totalPages = Math.ceil(sorted.length / DIGEST_PACKAGE_LIMIT);
const fields = chunk.map(({ result }) => ({
name: result.name || "Paket",
value: `${result.successfulFiles} Dateien · ${formatBytes(result.downloadedBytes)} · ${formatDuration(result.totalDurationSeconds)}`,
inline: false
}));
events.push(event(
`package-digest:${digestKey}:${page}`,
"package_completed",
"success",
createdAt,
totalPages > 1 ? `✅ Paket-Digest ${page}/${totalPages}` : "✅ Paket-Digest",
`${sorted.length} Pakete abgeschlossen`,
0x2ecc71,
fields
));
}
return events;
}
export function buildRunResult(input: RunResultInput): RunResult {
const packages = [...input.packages];
const sum = (select: (result: PackageResult) => number): number => packages.reduce((total, result) => total + finiteNonNegative(select(result)), 0);
const downloadedBytes = sum((result) => result.downloadedBytes);
const downloadDurationSeconds = sum((result) => result.downloadDurationSeconds);
const successfulFiles = input.successfulFiles ?? sum((result) => result.successfulFiles);
const failedFiles = input.failedFiles ?? sum((result) => result.failedFiles);
const cancelledFiles = input.cancelledFiles ?? sum((result) => result.cancelledFiles);
return {
id: input.id,
stopped: input.stopped,
startedAt: finiteNonNegative(input.startedAt),
completedAt: finiteNonNegative(input.completedAt),
totalDurationSeconds: Math.max(0, Math.floor((finiteNonNegative(input.completedAt) - finiteNonNegative(input.startedAt)) / 1000)),
totalPackages: Math.max(packages.length, Math.floor(finiteNonNegative(input.totalPackages))),
completedPackages: packages.filter((result) => result.status === "completed").length,
partialPackages: packages.filter((result) => result.status === "partial").length,
failedPackages: packages.filter((result) => result.status === "failed").length,
cancelledPackages: packages.filter((result) => result.status === "cancelled").length,
successfulFiles,
failedFiles,
cancelledFiles,
totalBytes: sum((result) => result.totalBytes),
downloadedBytes,
averageDownloadSpeedBps: downloadDurationSeconds > 0 ? Math.floor(downloadedBytes / downloadDurationSeconds) : 0,
downloadDurationSeconds,
extractionDurationSeconds: sum((result) => result.extractionDurationSeconds),
remuxDurationSeconds: sum((result) => result.remuxDurationSeconds),
postProcessDurationSeconds: sum((result) => result.postProcessDurationSeconds),
extractionFailures: packages.reduce((total, result) => total + result.archiveOperations.filter((operation) => operation.status === "failed").length, 0),
remuxFailures: packages.reduce((total, result) => total + result.remuxOperations.filter((operation) => operation.status === "failed").length, 0),
downloadFailures: packages.filter((result) => result.failurePhase === "download").reduce((total, result) => total + result.failedFiles, 0),
offlineFailures: packages.filter((result) => /offline|not found|nicht gefunden/i.test(result.errorCategory)).reduce((total, result) => total + result.failedFiles, 0)
};
}
export function buildRunNotificationEvent(result: RunResult): NotificationEvent {
const priority: NotificationPriority = result.stopped || result.failedFiles > 0 || result.partialPackages > 0 || result.failedPackages > 0
? "error"
: "success";
const type: NotificationEventType = result.stopped ? "run_stopped" : "run_completed";
const title = result.stopped
? "⏹️ Durchlauf gestoppt"
: priority === "success"
? "🏁 Durchlauf beendet"
: "⚠️ Durchlauf mit Fehlern beendet";
return event(
`run:${result.id}:${type}`,
type,
priority,
result.completedAt,
title,
result.stopped ? "Offene Dateien bleiben in der Warteschlange." : "Alle Paketresultate sind final.",
priority === "success" ? 0x2ecc71 : 0xe67e22,
[
{ name: "Pakete", value: `${result.completedPackages} fertig · ${result.partialPackages} teilweise · ${result.failedPackages} fehlgeschlagen · ${result.cancelledPackages} abgebrochen`, inline: false },
{ name: "Dateien", value: `${result.successfulFiles} erfolgreich · ${result.failedFiles} fehlgeschlagen · ${result.cancelledFiles} abgebrochen`, inline: false },
{ name: "Dauer", value: `Download ${formatDuration(result.downloadDurationSeconds)} · Entpacken ${formatDuration(result.extractionDurationSeconds)} · Remux ${formatDuration(result.remuxDurationSeconds)} · Nachbearbeitung ${formatDuration(result.postProcessDurationSeconds)} · Gesamt ${formatDuration(result.totalDurationSeconds)}`, inline: false },
{ name: "Downloadgröße", value: `${formatBytes(result.downloadedBytes)} / ${formatBytes(result.totalBytes)}`, inline: true },
{ name: "Aktive Downloadgeschwindigkeit", value: result.averageDownloadSpeedBps > 0 ? `${formatBytes(result.averageDownloadSpeedBps)}/s` : "—", inline: true },
{ name: "Entpackfehler", value: String(result.extractionFailures), inline: true },
{ name: "Remuxfehler", value: String(result.remuxFailures), inline: true },
{ name: "Downloadfehler", value: String(result.downloadFailures), inline: true },
{ name: "Offline", value: String(result.offlineFailures), inline: true }
]
);
}
export function buildHistoryEntry(
result: PackageResult,
context: HistoryEntryContext
): HistoryEntry {
return {
id: `hist-${result.packageId}-${context.generation}`,
name: result.name,
totalBytes: result.totalBytes,
downloadedBytes: result.downloadedBytes,
fileCount: result.successfulFiles + result.failedFiles + result.cancelledFiles,
provider: context.provider,
completedAt: result.completedAt,
durationSeconds: result.downloadDurationSeconds,
status: result.status,
outputDir: context.outputDir,
urls: [...new Set(context.urls.filter(Boolean))],
startedAt: result.startedAt,
downloadEndedAt: result.downloadEndedAt,
postProcessStartedAt: result.postProcessStartedAt,
downloadDurationSeconds: result.downloadDurationSeconds,
extractionDurationSeconds: result.extractionDurationSeconds,
remuxDurationSeconds: result.remuxDurationSeconds,
postProcessDurationSeconds: result.postProcessDurationSeconds,
totalDurationSeconds: result.totalDurationSeconds,
successfulFiles: result.successfulFiles,
failedFiles: result.failedFiles,
cancelledFiles: result.cancelledFiles,
archiveCount: result.archiveCount,
partCount: result.partCount,
outputCount: result.outputCount,
failurePhase: result.failurePhase,
archiveOperations: result.archiveOperations.map((operation) => ({ ...operation, itemIds: [...operation.itemIds] })),
remuxOperations: result.remuxOperations.map((operation) => ({ ...operation }))
};
}
+56 -1
View File
@@ -20,6 +20,7 @@ import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import { SlidingSelection } from "../../ui/SlidingSelection";
import {
createHistoryTableColumnWidths,
formatHistoryDuration,
getHistoryTableGridTemplate,
getHistoryTableMinWidth,
HISTORY_TABLE_COLUMN_IDS,
@@ -69,6 +70,12 @@ const HISTORY_DISCLOSURE_DURATION_MS = 520;
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
let historyTableResizeSession: { column: HistoryTableColumnId; startX: number; initial: HistoryTableColumnWidths } | null = null;
function operationStatusLabel(status: "completed" | "failed" | "cancelled"): string {
if (status === "completed") return "Abgeschlossen";
if (status === "failed") return "Fehlgeschlagen";
return "Abgebrochen";
}
function loadHistoryTableColumnWidths(): HistoryTableColumnWidths {
try {
const stored = typeof window === "undefined" ? null : window.localStorage.getItem(HISTORY_TABLE_COLUMN_STORAGE_KEY);
@@ -183,11 +190,59 @@ function HistoryRowDetails({
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
{row.hasStructuredLifecycle ? (
<>
<div><dt>Download gestartet</dt><dd>{row.startedLabel}</dd></div>
<div><dt>Download beendet</dt><dd>{row.downloadEndedLabel}</dd></div>
<div><dt>Nachbearbeitung gestartet</dt><dd>{row.postProcessStartedLabel}</dd></div>
<div><dt>Abgeschlossen</dt><dd>{row.completedLabel}</dd></div>
<div><dt>Downloaddauer</dt><dd>{row.downloadDurationLabel}</dd></div>
<div><dt>Entpackdauer</dt><dd>{row.extractionDurationLabel}</dd></div>
<div><dt>Remuxdauer</dt><dd>{row.remuxDurationLabel}</dd></div>
<div><dt>Nachbearbeitungsdauer</dt><dd>{row.postProcessDurationLabel}</dd></div>
<div><dt>Gesamtdauer</dt><dd>{row.totalDurationLabel}</dd></div>
<div><dt>Status</dt><dd>{row.statusLabel}</dd></div>
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
</>
) : (
<div><dt>Downloaddauer (Altbestand)</dt><dd>{row.durationLabel}</dd></div>
)}
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
{row.hasStructuredLifecycle ? (
<div className="history-operation-groups">
<section className="history-operation-group">
<h3>Archivvorgänge</h3>
{row.archiveOperations?.length ? (
<ul>
{row.archiveOperations.map((operation) => (
<li key={operation.id}>
<strong>{operation.name}</strong>
<span>{operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
</li>
))}
</ul>
) : <p>Keine Archivvorgänge</p>}
</section>
<section className="history-operation-group">
<h3>Remuxvorgänge</h3>
{row.remuxOperations?.length ? (
<ul>
{row.remuxOperations.map((operation) => (
<li key={operation.id}>
<strong>{operation.fileName}</strong>
<span>{formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
</li>
))}
</ul>
) : <p>Keine Remuxvorgänge</p>}
</section>
</div>
) : null}
</div>
</div>
</div>
+48 -9
View File
@@ -14,6 +14,15 @@ export interface HistoryRow extends HistoryViewEntry {
durationLabel: string;
averageSpeedLabel: string;
statusLabel: string;
hasStructuredLifecycle: boolean;
downloadEndedLabel: string;
postProcessStartedLabel: string;
downloadDurationLabel: string;
extractionDurationLabel: string;
remuxDurationLabel: string;
postProcessDurationLabel: string;
totalDurationLabel: string;
failurePhaseLabel: string;
}
export interface HistoryFilterCounts {
@@ -147,7 +156,7 @@ function formatBytes(bytes: number): string {
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatDuration(durationSeconds: number): string {
export function formatHistoryDuration(durationSeconds: number): string {
const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
@@ -158,6 +167,19 @@ function formatDuration(durationSeconds: number): string {
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function formatTimestamp(timestamp: number | undefined): string {
const safe = Math.max(0, Number.isFinite(timestamp) ? Number(timestamp) : 0);
return safe > 0 ? dateFormatter.format(new Date(safe)) : "—";
}
function failurePhaseLabel(entry: HistoryViewEntry): string {
if (entry.failurePhase === "download") return "Download";
if (entry.failurePhase === "extract") return "Entpacken";
if (entry.failurePhase === "remux") return "Remux";
if (entry.failurePhase === "cleanup") return "Aufräumen";
return "—";
}
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
const totalItems = rows.length;
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
@@ -234,7 +256,11 @@ export function deriveHistoryHoster(urls: string[] | undefined): string {
return hostnames.length > 0 ? hostnames.join(", ") : "—";
}
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds" | "startedAt">): number {
const startedAt = Math.max(0, Number.isFinite(entry.startedAt) ? Number(entry.startedAt) : 0);
if (startedAt > 0) {
return startedAt;
}
const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0);
const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000;
return Math.max(0, completedAt - durationMs);
@@ -244,19 +270,32 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
const hoster = deriveHistoryHoster(entry.urls);
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
const startAt = deriveHistoryStartAt(entry);
const durationSeconds = Math.max(0, entry.durationSeconds || 0);
const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0;
const downloadDurationSeconds = Math.max(0, entry.downloadDurationSeconds ?? entry.durationSeconds ?? 0);
const averageBytesPerSecond = downloadDurationSeconds > 0 ? entry.downloadedBytes / downloadDurationSeconds : 0;
const hasStructuredLifecycle = entry.startedAt !== undefined
|| entry.downloadEndedAt !== undefined
|| entry.postProcessStartedAt !== undefined
|| entry.totalDurationSeconds !== undefined;
return {
...entry,
hoster,
providerLabel,
startAt,
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
startedLabel: dateFormatter.format(new Date(startAt)),
completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))),
durationLabel: formatDuration(durationSeconds),
averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status]
startedLabel: formatTimestamp(startAt),
completedLabel: formatTimestamp(entry.completedAt),
durationLabel: formatHistoryDuration(downloadDurationSeconds),
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status],
hasStructuredLifecycle,
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt),
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt),
downloadDurationLabel: formatHistoryDuration(entry.downloadDurationSeconds ?? 0),
extractionDurationLabel: formatHistoryDuration(entry.extractionDurationSeconds ?? 0),
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0),
failurePhaseLabel: failurePhaseLabel(entry)
};
}
+72 -10
View File
@@ -339,11 +339,23 @@
color: var(--ui-danger-text);
}
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
color: var(--ui-danger-text);
}
}
.history-status-partial {
background: color-mix(in srgb, var(--ui-warning) 16%, transparent);
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
color: var(--ui-warning-text);
}
.history-status-cancelled {
background: color-mix(in srgb, var(--ui-text-muted) 14%, transparent);
border-color: color-mix(in srgb, var(--ui-text-muted) 48%, var(--ui-border));
color: var(--ui-text-secondary);
}
.history-row-size,
.history-row-hoster,
@@ -422,9 +434,55 @@
min-width: 0;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-operation-groups {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 12px;
}
.history-operation-group {
background: color-mix(in srgb, var(--ui-panel) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--ui-border) 82%, transparent);
border-radius: 6px;
min-width: 0;
padding: 10px 11px;
}
.history-operation-group h3 {
color: var(--ui-text-muted);
font-size: 11px;
margin: 0 0 8px;
text-transform: uppercase;
}
.history-operation-group p,
.history-operation-group ul {
color: var(--ui-text-secondary);
margin: 0;
}
.history-operation-group ul {
display: grid;
gap: 8px;
list-style: none;
padding: 0;
}
.history-operation-group li {
display: grid;
gap: 3px;
min-width: 0;
}
.history-operation-group strong,
.history-operation-group span {
overflow-wrap: anywhere;
}
.history-copyable {
overflow-wrap: anywhere;
@@ -494,9 +552,13 @@
overflow: hidden;
}
.history-action {
padding: 0 9px;
}
.history-action {
padding: 0 9px;
}
.history-operation-groups {
grid-template-columns: 1fr;
}
}