mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
replace app_tracking_summary apps array with state-breakdown counters and add InstallProgress tracker
Fixes Azure Table Storage 64KB truncation by removing the full apps array from app_tracking_summary (could be 150+ apps) and replacing it with lightweight state-breakdown counters (downloading, installing, installed, skipped, failed, pending). Summary emission is now event-driven with 30s timer as backstop. New InstallProgress UI component shows per-app install state with duration tracking. ConfigVersion 10 -> 11. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -287,7 +287,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
{ "hasErrors", HasError },
|
||||
{ "isAllCompleted", IsAllCompleted() },
|
||||
{ "ignoredCount", IgnoreList.Count },
|
||||
{ "apps", this.Select(x => x.ToEventData()).ToList() }
|
||||
// State-breakdown for UI progress displays (replaces the full "apps" array
|
||||
// which could exceed Azure Table Storage's 64KB property limit with 150+ apps)
|
||||
{ "downloading", this.Count(x => x.InstallationState == AppInstallationState.Downloading) },
|
||||
{ "installing", this.Count(x => x.InstallationState == AppInstallationState.Installing) },
|
||||
{ "installed", this.Count(x => x.InstallationState == AppInstallationState.Installed) },
|
||||
{ "skipped", this.Count(x => x.InstallationState == AppInstallationState.Skipped) },
|
||||
{ "failed", this.Count(x => x.InstallationState == AppInstallationState.Error || x.InstallationState == AppInstallationState.Postponed) },
|
||||
{ "pending", this.Count(x => x.InstallationState < AppInstallationState.Downloading) }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
@@ -32,6 +33,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation
|
||||
private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete
|
||||
private bool _finalDeviceInfoCollected = false; // Ensure final device info is emitted only once
|
||||
private string _lastEmittedSummaryHash; // Track last emitted state-breakdown to avoid redundant summary events
|
||||
|
||||
// Default IME log folder
|
||||
private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
|
||||
@@ -399,6 +401,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
Message = message,
|
||||
Data = app.ToEventData()
|
||||
});
|
||||
|
||||
// Emit summary immediately if state-breakdown counters changed (instant UI updates)
|
||||
EmitAppTrackingSummaryIfChanged();
|
||||
}
|
||||
|
||||
private void HandlePoliciesDiscovered(string policiesJson)
|
||||
@@ -652,17 +657,24 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
|
||||
private void SummaryTimerCallback(object state)
|
||||
{
|
||||
if (_imeLogTracker?.PackageStates?.CountAll > 0)
|
||||
{
|
||||
EmitAppTrackingSummary();
|
||||
}
|
||||
// Only emit if state-breakdown counters changed since last emission (backstop for missed events)
|
||||
EmitAppTrackingSummaryIfChanged();
|
||||
}
|
||||
|
||||
private void EmitAppTrackingSummary()
|
||||
/// <summary>
|
||||
/// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
|
||||
/// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
|
||||
/// </summary>
|
||||
private void EmitAppTrackingSummaryIfChanged()
|
||||
{
|
||||
var states = _imeLogTracker?.PackageStates;
|
||||
if (states == null || states.CountAll == 0) return;
|
||||
|
||||
var hash = GetSummaryHash(states);
|
||||
if (hash == _lastEmittedSummaryHash) return;
|
||||
|
||||
_lastEmittedSummaryHash = hash;
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
@@ -677,6 +689,38 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits app_tracking_summary unconditionally (for final summary on completion).
|
||||
/// </summary>
|
||||
private void EmitAppTrackingSummary()
|
||||
{
|
||||
var states = _imeLogTracker?.PackageStates;
|
||||
if (states == null || states.CountAll == 0) return;
|
||||
|
||||
_lastEmittedSummaryHash = GetSummaryHash(states);
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "app_tracking_summary",
|
||||
Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
|
||||
(states.HasError ? $" ({states.ErrorCount} errors)" : ""),
|
||||
Data = states.GetSummaryData()
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetSummaryHash(AppPackageStateList states)
|
||||
{
|
||||
return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
|
||||
$"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
|
||||
$"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
|
||||
$"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
|
||||
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 10, // agent-side analyzers (LocalAdminAnalyzer) with AnalyzerConfiguration
|
||||
ConfigVersion = 11, // app_tracking_summary: replaced apps array with state-breakdown counters + event-driven emission
|
||||
UploadIntervalSeconds = 30,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
|
||||
const trackingSummary = phaseEvents.find(e => e.eventType === "app_tracking_summary");
|
||||
if (trackingSummary?.data) {
|
||||
const d = trackingSummary.data;
|
||||
const completed = parseInt(d.appsCompleted ?? "0", 10);
|
||||
const completed = parseInt(d.completedApps ?? d.appsCompleted ?? "0", 10);
|
||||
const total = parseInt(d.totalApps ?? "0", 10);
|
||||
if (total > 0) {
|
||||
return `Installing apps (${completed}/${total})`;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { ProtectedRoute } from '../../../components/ProtectedRoute';
|
||||
import PerformanceChart from '../../../components/PerformanceChart';
|
||||
import DownloadProgress from '../../../components/DownloadProgress';
|
||||
import InstallProgress from '../../../components/InstallProgress';
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
|
||||
import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "./utils/phaseConstants";
|
||||
@@ -555,6 +556,25 @@ export default function SessionDetailPage() {
|
||||
[events, severityFilters]
|
||||
);
|
||||
|
||||
// Extract latest app_tracking_summary state-breakdown for progress headers
|
||||
const appSummaryStats = useMemo(() => {
|
||||
const summaryEvents = events.filter(e => e.eventType === "app_tracking_summary");
|
||||
if (summaryEvents.length === 0) return null;
|
||||
const latest = summaryEvents[summaryEvents.length - 1];
|
||||
const d = latest.data;
|
||||
if (!d) return null;
|
||||
return {
|
||||
totalApps: parseInt(d.totalApps ?? "0", 10),
|
||||
completedApps: parseInt(d.completedApps ?? "0", 10),
|
||||
downloading: parseInt(d.downloading ?? "0", 10),
|
||||
installing: parseInt(d.installing ?? "0", 10),
|
||||
installed: parseInt(d.installed ?? "0", 10),
|
||||
skipped: parseInt(d.skipped ?? "0", 10),
|
||||
failed: parseInt(d.failed ?? "0", 10),
|
||||
pending: parseInt(d.pending ?? "0", 10),
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
const isGatherRulesSession = session?.enrollmentType === "gather_rules";
|
||||
// For gather_rules sessions: if the completed event is present, derive status as Succeeded
|
||||
// (the backend never sets this status automatically for one-shot gather runs).
|
||||
@@ -839,12 +859,23 @@ export default function SessionDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Download Progress (from download_progress, app_download_started, app_install_skipped, or app_tracking_summary events) */}
|
||||
{/* Download Progress (from download_progress, app_download_started, app_install_skipped events) */}
|
||||
{!isGatherRulesSession && (
|
||||
<DownloadProgress
|
||||
events={events.filter(
|
||||
e => e.eventType === "download_progress" || e.eventType === "app_download_started" || e.eventType === "app_install_skipped" || e.eventType === "app_tracking_summary"
|
||||
e => e.eventType === "download_progress" || e.eventType === "app_download_started" || e.eventType === "app_install_skipped"
|
||||
)}
|
||||
summaryStats={appSummaryStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Install Progress (from app_install_started, app_install_completed, app_install_failed, app_install_skipped events) */}
|
||||
{!isGatherRulesSession && (
|
||||
<InstallProgress
|
||||
events={events.filter(
|
||||
e => e.eventType === "app_install_started" || e.eventType === "app_install_completed" || e.eventType === "app_install_failed" || e.eventType === "app_install_skipped"
|
||||
)}
|
||||
summaryStats={appSummaryStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,8 +8,19 @@ interface DownloadEvent {
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SummaryStats {
|
||||
totalApps?: number;
|
||||
downloading?: number;
|
||||
installed?: number;
|
||||
installing?: number;
|
||||
skipped?: number;
|
||||
failed?: number;
|
||||
pending?: number;
|
||||
}
|
||||
|
||||
interface DownloadProgressProps {
|
||||
events: DownloadEvent[];
|
||||
summaryStats?: SummaryStats | null;
|
||||
}
|
||||
|
||||
interface DownloadItem {
|
||||
@@ -41,7 +52,7 @@ function formatSpeed(bps: number): string {
|
||||
return `${(bps / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function DownloadProgress({ events }: DownloadProgressProps) {
|
||||
export default function DownloadProgress({ events, summaryStats }: DownloadProgressProps) {
|
||||
const downloads = useMemo(() => {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
@@ -141,6 +152,14 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
|
||||
const completedCount = downloads.filter(d => d.isComplete && !d.isSkipped).length;
|
||||
const skippedCount = downloads.filter(d => d.isSkipped).length;
|
||||
|
||||
// "X of Y downloaded" from summary stats: apps past download phase / total apps that actually download
|
||||
const totalDownloadable = summaryStats?.totalApps != null && summaryStats?.skipped != null && summaryStats?.pending != null
|
||||
? summaryStats.totalApps - summaryStats.skipped
|
||||
: null;
|
||||
const downloadedCount = summaryStats?.installed != null && summaryStats?.installing != null && summaryStats?.failed != null
|
||||
? summaryStats.installed + summaryStats.installing + summaryStats.failed
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<button
|
||||
@@ -152,7 +171,11 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Download Progress</h2>
|
||||
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
|
||||
{totalDownloadable != null && downloadedCount != null ? (
|
||||
<span className="text-xs text-gray-400">({downloadedCount} of {totalDownloadable} downloaded)</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
interface InstallEvent {
|
||||
timestamp: string;
|
||||
eventType?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SummaryStats {
|
||||
totalApps?: number;
|
||||
installing?: number;
|
||||
installed?: number;
|
||||
failed?: number;
|
||||
}
|
||||
|
||||
interface InstallProgressProps {
|
||||
events: InstallEvent[];
|
||||
summaryStats?: SummaryStats | null;
|
||||
}
|
||||
|
||||
interface InstallItem {
|
||||
appName: string;
|
||||
appId: string;
|
||||
state: "Installing" | "Installed" | "Failed" | "Skipped";
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
isCompleted: boolean;
|
||||
isError: boolean;
|
||||
errorDetail?: string;
|
||||
errorPatternId?: string;
|
||||
firstSeenIndex: number;
|
||||
eventData?: Record<string, any>;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
export default function InstallProgress({ events, summaryStats }: InstallProgressProps) {
|
||||
const installs = useMemo(() => {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
const sortedEvents = [...events].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
const installMap = new Map<string, InstallItem>();
|
||||
let insertionIndex = 0;
|
||||
|
||||
for (const evt of sortedEvents) {
|
||||
const d = evt.data;
|
||||
if (!d) continue;
|
||||
|
||||
const appName = d.appName ?? d.app_name ?? d.appId ?? d.app_id ?? "Unknown App";
|
||||
if (appName === "Unknown App") continue;
|
||||
|
||||
const appId = d.appId ?? d.app_id ?? appName;
|
||||
const existing = installMap.get(appName);
|
||||
const eventTs = evt.timestamp;
|
||||
|
||||
if (evt.eventType === "app_install_started") {
|
||||
installMap.set(appName, {
|
||||
appName,
|
||||
appId,
|
||||
state: "Installing",
|
||||
startedAt: existing?.startedAt ?? eventTs,
|
||||
isCompleted: false,
|
||||
isError: false,
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
eventData: d,
|
||||
});
|
||||
} else if (evt.eventType === "app_install_completed") {
|
||||
const startTime = existing?.startedAt ? new Date(existing.startedAt).getTime() : null;
|
||||
const endTime = new Date(eventTs).getTime();
|
||||
const duration = startTime ? endTime - startTime : undefined;
|
||||
|
||||
installMap.set(appName, {
|
||||
appName,
|
||||
appId,
|
||||
state: "Installed",
|
||||
startedAt: existing?.startedAt,
|
||||
completedAt: eventTs,
|
||||
durationMs: duration,
|
||||
isCompleted: true,
|
||||
isError: false,
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
eventData: d,
|
||||
});
|
||||
} else if (evt.eventType === "app_install_failed") {
|
||||
const startTime = existing?.startedAt ? new Date(existing.startedAt).getTime() : null;
|
||||
const endTime = new Date(eventTs).getTime();
|
||||
const duration = startTime ? endTime - startTime : undefined;
|
||||
|
||||
installMap.set(appName, {
|
||||
appName,
|
||||
appId,
|
||||
state: "Failed",
|
||||
startedAt: existing?.startedAt,
|
||||
completedAt: eventTs,
|
||||
durationMs: duration,
|
||||
isCompleted: true,
|
||||
isError: true,
|
||||
errorDetail: d.errorDetail ?? d.error_detail,
|
||||
errorPatternId: d.errorPatternId ?? d.error_pattern_id,
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
eventData: d,
|
||||
});
|
||||
} else if (evt.eventType === "app_install_skipped") {
|
||||
installMap.set(appName, {
|
||||
appName,
|
||||
appId,
|
||||
state: "Skipped",
|
||||
isCompleted: true,
|
||||
isError: false,
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
eventData: d,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(installMap.values()).sort((a, b) => a.firstSeenIndex - b.firstSeenIndex);
|
||||
}, [events]);
|
||||
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (installs.length === 0) return null;
|
||||
|
||||
const activeCount = installs.filter(d => d.state === "Installing").length;
|
||||
const completedCount = installs.filter(d => d.state === "Installed").length;
|
||||
const failedCount = installs.filter(d => d.state === "Failed").length;
|
||||
const skippedCount = installs.filter(d => d.state === "Skipped").length;
|
||||
|
||||
// Use summary stats for "X of Y" if available, fall back to local event counts
|
||||
const totalFromSummary = summaryStats?.totalApps;
|
||||
const installedFromSummary = summaryStats?.installed;
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-5 h-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Install Progress</h2>
|
||||
{totalFromSummary != null && installedFromSummary != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
({installedFromSummary} of {totalFromSummary} installed)
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
{activeCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium">
|
||||
{activeCount} active
|
||||
</span>
|
||||
)}
|
||||
{completedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
|
||||
{completedCount} completed
|
||||
</span>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
|
||||
{failedCount} failed
|
||||
</span>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium">
|
||||
{skippedCount} skipped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400">{expanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
|
||||
{expanded && <div className="space-y-3 mt-4">
|
||||
{installs.map((item) => (
|
||||
<InstallItemRow key={item.appName} item={item} />
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallItemRow({ item }: { item: InstallItem }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const containerClass = item.state === "Skipped"
|
||||
? "bg-gray-50 border border-gray-300"
|
||||
: item.isError
|
||||
? "bg-red-50 border border-red-200"
|
||||
: item.isCompleted
|
||||
? "bg-green-50 border border-green-200"
|
||||
: "bg-gray-50 border border-gray-200";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-3 ${containerClass}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
{item.state === "Skipped" ? (
|
||||
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : item.isError ? (
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : item.isCompleted ? (
|
||||
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-indigo-500 flex-shrink-0 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={`text-sm font-medium truncate ${item.state === "Skipped" ? "text-gray-500" : "text-gray-900"}`}>
|
||||
{item.appName}
|
||||
</span>
|
||||
{item.state === "Skipped" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">Skipped</span>
|
||||
)}
|
||||
{item.state === "Failed" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-200 text-red-700 font-medium">Failed</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 text-xs text-gray-500 flex-shrink-0 ml-2">
|
||||
{item.durationMs != null && item.durationMs > 0 && (
|
||||
<span className="font-medium">{formatDuration(item.durationMs)}</span>
|
||||
)}
|
||||
{item.eventData && Object.keys(item.eventData).length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error detail */}
|
||||
{item.isError && item.errorDetail && (
|
||||
<div className="mt-1 text-xs text-red-600">
|
||||
{item.errorDetail}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event details (expandable) */}
|
||||
{showDetails && item.eventData && (
|
||||
<div className="mt-3 p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
|
||||
<pre>{JSON.stringify(item.eventData, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user