fixed app install summaries

This commit is contained in:
Oliver Kieselbach
2026-02-10 16:14:19 +01:00
parent 6c444c1c73
commit dadb897e8e
5 changed files with 110 additions and 46 deletions
@@ -35,7 +35,17 @@ namespace AutopilotMonitor.Functions.Functions
var tenantId = TenantHelper.GetTenantId(req);
_logger.LogInformation($"Fetching app metrics for tenant {tenantId}");
var summaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId);
// Optional time filter: ?days=7 (default: 30)
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var daysParam = query["days"];
int days = 30;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
var cutoff = DateTime.UtcNow.AddDays(-days);
var allSummaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId);
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
// Aggregate: slowest apps (avg duration) and top failing apps
var appGroups = summaries.GroupBy(s => s.AppName).Select(g =>
@@ -64,7 +74,7 @@ namespace AutopilotMonitor.Functions.Functions
}).ToList();
var slowestApps = appGroups
.Where(a => a.avgDurationSeconds > 0)
.Where(a => a.succeeded > 0)
.OrderByDescending(a => a.avgDurationSeconds)
.Take(10)
.ToList();
@@ -406,18 +406,23 @@ namespace AutopilotMonitor.Functions.Functions
/// </summary>
private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary<string, AppInstallSummary> summaries)
{
string? appName = null;
// Agent sends: app_install_started, app_install_completed, app_install_failed, app_download_started, download_progress
// Support both legacy (app_install_start/complete) and current agent event types
bool isRelevant =
evt.EventType == "app_install_started" || evt.EventType == "app_install_start" ||
evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" ||
evt.EventType == "app_install_failed" ||
evt.EventType == "app_download_started" ||
evt.EventType == "app_install_skipped" ||
evt.EventType == "download_progress";
if (evt.EventType == "app_install_start" || evt.EventType == "app_install_complete" ||
evt.EventType == "app_install_failed" || evt.EventType == "download_progress")
{
appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString() : null;
if (string.IsNullOrEmpty(appName)) return;
}
else
{
return;
}
if (!isRelevant) return;
// Agent sends "appName" (and also "name") in the Data dictionary
var appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString() : null;
if (string.IsNullOrEmpty(appName))
appName = evt.Data?.ContainsKey("name") == true ? evt.Data["name"]?.ToString() : null;
if (string.IsNullOrEmpty(appName)) return;
if (!summaries.TryGetValue(appName, out var summary))
{
@@ -433,44 +438,49 @@ namespace AutopilotMonitor.Functions.Functions
switch (evt.EventType)
{
case "app_install_started":
case "app_install_start":
summary.Status = "InProgress";
summary.StartedAt = evt.Timestamp;
case "app_download_started":
// Only set StartedAt on the very first start event
if (summary.StartedAt == DateTime.MinValue || summary.StartedAt > evt.Timestamp)
summary.StartedAt = evt.Timestamp;
if (summary.Status == "InProgress" || summary.Status == string.Empty)
summary.Status = "InProgress";
break;
case "app_install_completed":
case "app_install_complete":
summary.Status = "Succeeded";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
{
summary.DurationSeconds = (int)(evt.Timestamp - summary.StartedAt).TotalSeconds;
}
summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds);
break;
case "app_install_failed":
summary.Status = "Failed";
summary.CompletedAt = evt.Timestamp;
if (summary.StartedAt != DateTime.MinValue)
{
summary.DurationSeconds = (int)(evt.Timestamp - summary.StartedAt).TotalSeconds;
}
summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds);
// Agent does not send errorCode/errorMessage in Data — use the event message
summary.FailureCode = evt.Data?.ContainsKey("errorCode") == true
? evt.Data["errorCode"]?.ToString() ?? string.Empty : string.Empty;
summary.FailureMessage = evt.Data?.ContainsKey("errorMessage") == true
? evt.Data["errorMessage"]?.ToString() ?? string.Empty : evt.Message ?? string.Empty;
break;
case "app_install_skipped":
// Mark as succeeded (skipped = already installed / not applicable) with 0 duration
if (summary.Status == "InProgress")
summary.Status = "Succeeded";
break;
case "download_progress":
if (evt.Data?.ContainsKey("bytes_downloaded") == true &&
long.TryParse(evt.Data["bytes_downloaded"]?.ToString(), out var bytes))
{
// Agent sends "bytesDownloaded" (not "bytes_downloaded")
var bytesKey = evt.Data?.ContainsKey("bytesDownloaded") == true ? "bytesDownloaded"
: evt.Data?.ContainsKey("bytes_downloaded") == true ? "bytes_downloaded" : null;
if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes))
summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes);
}
if (evt.Data?.ContainsKey("download_duration_seconds") == true &&
int.TryParse(evt.Data["download_duration_seconds"]?.ToString(), out var dlDuration))
{
summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, dlDuration);
}
// download_duration_seconds is not sent by the agent — skip
break;
}
}
@@ -1275,7 +1275,8 @@ namespace AutopilotMonitor.Functions.Services
// ===== APP INSTALL SUMMARIES METHODS =====
/// <summary>
/// Stores or updates an app install summary
/// Stores or updates an app install summary.
/// Merges with any existing record so StartedAt is never overwritten with a later timestamp.
/// PartitionKey: TenantId, RowKey: {SessionId}_{AppName}
/// </summary>
public async Task<bool> StoreAppInstallSummaryAsync(AppInstallSummary summary)
@@ -1285,6 +1286,29 @@ namespace AutopilotMonitor.Functions.Services
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
var rowKey = $"{summary.SessionId}_{summary.AppName}";
// Merge with existing record to preserve StartedAt from a prior batch
try
{
var existing = await tableClient.GetEntityAsync<TableEntity>(summary.TenantId, rowKey);
var existingStartedAt = existing.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime;
if (existingStartedAt.HasValue && existingStartedAt.Value != DateTime.MinValue)
{
// Keep the earlier StartedAt; recalculate duration if CompletedAt is now known
if (summary.StartedAt == DateTime.MinValue || existingStartedAt.Value < summary.StartedAt)
{
summary.StartedAt = existingStartedAt.Value;
if (summary.CompletedAt.HasValue && summary.DurationSeconds == 0)
{
summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds;
}
}
}
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
// No existing record – nothing to merge
}
var entity = new TableEntity(summary.TenantId, rowKey)
{
["AppName"] = summary.AppName ?? string.Empty,
@@ -59,9 +59,11 @@ namespace AutopilotMonitor.Shared
public static class EventTypes
{
public const string PhaseTransition = "phase_transition";
public const string AppInstallStart = "app_install_start";
public const string AppInstallComplete = "app_install_complete";
public const string AppInstallStart = "app_install_started";
public const string AppInstallComplete = "app_install_completed";
public const string AppInstallFailed = "app_install_failed";
public const string AppDownloadStarted = "app_download_started";
public const string AppInstallSkipped = "app_install_skipped";
public const string NetworkStateChange = "network_state_change";
public const string ErrorDetected = "error_detected";
public const string PerformanceSnapshot = "performance_snapshot";
@@ -52,6 +52,7 @@ export default function FleetHealthPage() {
const hasInitialFetch = useRef(false);
const hasJoinedGroup = useRef(false);
const isTimeRangeMount = useRef(true);
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
@@ -68,9 +69,17 @@ export default function FleetHealthPage() {
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
fetchSessions();
fetchAppMetrics();
fetchAppMetrics(timeRange);
}, []);
useEffect(() => {
if (isTimeRangeMount.current) {
isTimeRangeMount.current = false;
return;
}
fetchAppMetrics(timeRange);
}, [timeRange]);
useEffect(() => {
if (isConnected && !hasJoinedGroup.current) {
joinGroup(`tenant-${tenantId}`);
@@ -144,12 +153,13 @@ export default function FleetHealthPage() {
}
};
const fetchAppMetrics = async () => {
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
try {
const token = await getAccessToken();
if (!token) return;
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const response = await fetch(
`${API_BASE_URL}/api/app-metrics?tenantId=${tenantId}`,
`${API_BASE_URL}/api/app-metrics?tenantId=${tenantId}&days=${days}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.ok) {
@@ -662,10 +672,21 @@ export default function FleetHealthPage() {
) : (
<div className="space-y-3">
{appMetrics.slowestApps.map((app, i) => {
const avgMinutes = Math.round(app.avgDurationSeconds / 60);
const maxMinutes = Math.round(app.maxDurationSeconds / 60);
const avgSec = app.avgDurationSeconds;
const avgLabel = avgSec >= 60
? `${Math.round(avgSec / 60)} min`
: avgSec > 0
? `${Math.round(avgSec)}s`
: "< 1s";
const maxSec = app.maxDurationSeconds;
const maxLabel = maxSec >= 60
? `${Math.round(maxSec / 60)}m`
: maxSec > 0
? `${Math.round(maxSec)}s`
: "< 1s";
const maxAvg = Math.max(
...appMetrics.slowestApps.map((a) => a.avgDurationSeconds)
1,
...appMetrics!.slowestApps.map((a) => a.avgDurationSeconds)
);
return (
<div
@@ -681,7 +702,7 @@ export default function FleetHealthPage() {
{app.appName}
</span>
<span className="text-sm font-medium text-gray-900 flex-shrink-0">
{avgMinutes} min avg
{avgLabel} avg
</span>
</div>
<div className="flex items-center space-x-2">
@@ -689,16 +710,13 @@ export default function FleetHealthPage() {
<div
className="h-full bg-amber-400 rounded-full"
style={{
width: `${
maxAvg > 0
? (app.avgDurationSeconds / maxAvg) * 100
: 0
}%`,
width: `${(avgSec / maxAvg) * 100}%`,
minWidth: avgSec > 0 ? "2px" : "0",
}}
/>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
max {maxMinutes}m | {app.succeeded} installs
max {maxLabel} | {app.succeeded} installs
</span>
</div>
</div>