mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fixed app install summaries
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user