diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 73e7a666..4a8e7b55 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -489 +490 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs new file mode 100644 index 00000000..6c90cbb5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -0,0 +1,249 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Extensions; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Azure; +using Azure.Data.Tables; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Feedback +{ + public class FeedbackFunction + { + private readonly ILogger _logger; + private readonly TenantConfigurationService _tenantConfigService; + private readonly AdminConfigurationService _adminConfigService; + private readonly TenantAdminsService _tenantAdminsService; + private readonly TelegramNotificationService _telegramNotificationService; + private readonly TableStorageService _storageService; + private readonly TableClient _feedbackTableClient; + + public FeedbackFunction( + ILogger logger, + TenantConfigurationService tenantConfigService, + AdminConfigurationService adminConfigService, + TenantAdminsService tenantAdminsService, + TelegramNotificationService telegramNotificationService, + TableStorageService storageService, + IConfiguration configuration) + { + _logger = logger; + _tenantConfigService = tenantConfigService; + _adminConfigService = adminConfigService; + _tenantAdminsService = tenantAdminsService; + _telegramNotificationService = telegramNotificationService; + _storageService = storageService; + + var connectionString = configuration["AzureTableStorageConnectionString"]; + var serviceClient = new TableServiceClient(connectionString); + _feedbackTableClient = serviceClient.GetTableClient(Constants.TableNames.PreviewConfig); + } + + /// + /// Checks whether the current user is eligible to see the feedback bubble. + /// Evaluates: kill-switch, role, tenant age, session existence, cooldown. + /// + [Function("GetFeedbackStatus")] + public async Task GetStatus( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/status")] HttpRequestData req) + { + try + { + // 1. Kill-switch + var adminConfig = await _adminConfigService.GetConfigurationAsync(); + if (!adminConfig.FeedbackEnabled) + return await WriteJson(req, new { eligible = false }); + + // 2. User identity + string tenantId = TenantHelper.GetTenantId(req); + string upn = TenantHelper.GetUserIdentifier(req); + + // 3. Role check — only Admin + Operator + var roleInfo = await _tenantAdminsService.GetMemberRoleAsync(tenantId, upn); + if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer) + return await WriteJson(req, new { eligible = false }); + + // 4. Tenant age check + var tenantConfig = await _tenantConfigService.GetConfigurationAsync(tenantId); + if (tenantConfig.OnboardedAt == null || + (DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays) + return await WriteJson(req, new { eligible = false }); + + // 5. Sessions check — at least 1 session exists + var sessionPage = await _storageService.GetSessionsAsync(tenantId, maxResults: 1); + if (sessionPage.Sessions.Count == 0) + return await WriteJson(req, new { eligible = false }); + + // 6. Cooldown check + try + { + var entity = await _feedbackTableClient.GetEntityAsync("Feedback", upn.ToLowerInvariant()); + var interactedAt = entity.Value.GetDateTime("InteractedAt"); + + if (interactedAt.HasValue) + { + // Cooldown = 0 means single wave only + if (adminConfig.FeedbackCooldownDays == 0) + return await WriteJson(req, new { eligible = false }); + + var daysSinceInteraction = (DateTime.UtcNow - interactedAt.Value).TotalDays; + if (daysSinceInteraction < adminConfig.FeedbackCooldownDays) + return await WriteJson(req, new { eligible = false }); + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // No feedback record yet — user is eligible + } + + return await WriteJson(req, new { eligible = true }); + } + catch (UnauthorizedAccessException) + { + var response = req.CreateResponse(HttpStatusCode.Unauthorized); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking feedback status"); + // Fail closed — don't show bubble on errors + return await WriteJson(req, new { eligible = false }); + } + } + + /// + /// Submits user feedback or records a dismissal. + /// On submit: stores feedback and sends Telegram notification. + /// On dismiss: stores dismissal so the bubble is not shown again (until cooldown). + /// + [Function("SubmitFeedback")] + public async Task Submit( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "feedback")] HttpRequestData req) + { + try + { + string tenantId = TenantHelper.GetTenantId(req); + string upn = TenantHelper.GetUserIdentifier(req); + var principal = req.FunctionContext.GetUser(); + string displayName = principal?.GetDisplayName() ?? upn; + + var body = await req.ReadFromJsonAsync(); + if (body == null) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid request body" }); + return badRequest; + } + + // Validate rating + if (!body.Dismissed && (body.Rating < 1 || body.Rating > 5)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "Rating must be between 1 and 5" }); + return badRequest; + } + + // Trim comment + var comment = body.Comment?.Trim(); + if (comment?.Length > 500) + comment = comment.Substring(0, 500); + + // Upsert feedback record + var entity = new TableEntity("Feedback", upn.ToLowerInvariant()) + { + { "TenantId", tenantId }, + { "DisplayName", displayName }, + { "Rating", body.Dismissed ? null : (int?)body.Rating }, + { "Comment", body.Dismissed ? null : comment }, + { "Dismissed", body.Dismissed }, + { "Submitted", !body.Dismissed }, + { "InteractedAt", DateTime.UtcNow } + }; + + await _feedbackTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + + // Telegram notification — only for actual submissions, fire-and-forget + if (!body.Dismissed && body.Rating > 0) + { + _ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, body.Rating, comment); + } + + _logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})", + body.Dismissed ? "dismissed" : "submitted", upn, tenantId); + + return await WriteJson(req, new { success = true }); + } + catch (UnauthorizedAccessException) + { + return req.CreateResponse(HttpStatusCode.Unauthorized); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error submitting feedback"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); + return errorResponse; + } + } + + /// + /// Returns all feedback entries for the Galactic Admin dashboard. + /// + [Function("GetAllFeedback")] + public async Task GetAll( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/all")] HttpRequestData req) + { + try + { + var entries = new System.Collections.Generic.List(); + + await foreach (var entity in _feedbackTableClient.QueryAsync( + filter: "PartitionKey eq 'Feedback'")) + { + entries.Add(new + { + upn = entity.RowKey, + tenantId = entity.GetString("TenantId"), + displayName = entity.GetString("DisplayName"), + rating = entity.GetInt32("Rating"), + comment = entity.GetString("Comment"), + dismissed = entity.GetBoolean("Dismissed") ?? false, + submitted = entity.GetBoolean("Submitted") ?? false, + interactedAt = entity.GetDateTime("InteractedAt")?.ToString("o") + }); + } + + return await WriteJson(req, new { feedback = entries }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching all feedback"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); + return errorResponse; + } + } + + private static async Task WriteJson(HttpRequestData req, object data) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(data); + return response; + } + } + + public class FeedbackRequest + { + public int Rating { get; set; } + public string? Comment { get; set; } + public bool Dismissed { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 87e371e5..7250b53f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -97,6 +97,8 @@ public static class EndpointAccessPolicyCatalog new("GET", "progress/sessions", EndpointPolicy.AuthenticatedUser), new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser), new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser), + new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser), + new("POST", "feedback", EndpointPolicy.AuthenticatedUser), // ── MemberRead (Admin + Operator, later + Viewer) ─────────────── new("GET", "sessions", EndpointPolicy.MemberRead), @@ -179,6 +181,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "health/detailed", EndpointPolicy.AuthenticatedUser), new("POST", "rules/reseed-from-github", EndpointPolicy.GalacticAdminOnly), new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GalacticAdminOnly), + new("GET", "feedback/all", EndpointPolicy.GalacticAdminOnly), new("GET", "galactic/notifications", EndpointPolicy.GalacticAdminOnly), new("POST", "galactic/notifications/dismiss-all", EndpointPolicy.GalacticAdminOnly), new("POST", "galactic/notifications/{notificationId}/dismiss", EndpointPolicy.GalacticAdminOnly), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 17971867..931df1c8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -45,6 +45,7 @@ namespace AutopilotMonitor.Functions.Services private readonly UsageMetricsService _usageMetricsService; private readonly AdminConfigurationService _adminConfigurationService; private readonly BlockedDeviceService _blockedDeviceService; + private readonly TenantAdminsService _tenantAdminsService; private readonly ILogger _logger; private const string PlatformStatsAliasFileName = "platform-stats.json"; @@ -57,6 +58,7 @@ namespace AutopilotMonitor.Functions.Services UsageMetricsService usageMetricsService, AdminConfigurationService adminConfigurationService, BlockedDeviceService blockedDeviceService, + TenantAdminsService tenantAdminsService, ILogger logger) { _storageService = storageService; @@ -64,6 +66,7 @@ namespace AutopilotMonitor.Functions.Services _usageMetricsService = usageMetricsService; _adminConfigurationService = adminConfigurationService; _blockedDeviceService = blockedDeviceService; + _tenantAdminsService = tenantAdminsService; _logger = logger; } @@ -86,6 +89,9 @@ namespace AutopilotMonitor.Functions.Services // Safety net: backfill any sessions missing from SessionsIndex await _storageService.BackfillSessionIndexAsync(); + // Backfill OnboardedAt for tenants that don't have it yet + await BackfillTenantOnboardedAtAsync(); + maintenanceStart.Stop(); _logger.LogInformation($"Daily maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms"); } @@ -317,6 +323,61 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Backfills OnboardedAt for tenants that don't have it set yet. + /// Derives the value from the earliest TenantAdmin AddedDate for each tenant. + /// Self-healing: runs every maintenance cycle, no-ops once all tenants are backfilled. + /// + private async Task BackfillTenantOnboardedAtAsync() + { + _logger.LogInformation("Backfilling OnboardedAt for tenants..."); + + try + { + var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync(); + var configsWithoutOnboardedAt = allConfigs.Where(c => c.OnboardedAt == null).ToList(); + + if (configsWithoutOnboardedAt.Count == 0) + { + _logger.LogInformation("All tenants already have OnboardedAt set"); + return; + } + + int backfilledCount = 0; + + foreach (var config in configsWithoutOnboardedAt) + { + try + { + var admins = await _tenantAdminsService.GetTenantAdminsAsync(config.TenantId); + + if (admins.Count == 0) + { + // No admins yet — use LastUpdated as fallback (config creation date) + config.OnboardedAt = config.LastUpdated; + } + else + { + config.OnboardedAt = admins.Min(a => a.AddedDate); + } + + await _tenantConfigService.SaveConfigurationAsync(config); + backfilledCount++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to backfill OnboardedAt for tenant {TenantId}", config.TenantId); + } + } + + _logger.LogInformation("OnboardedAt backfill completed: {Count} tenants updated", backfilledCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to backfill OnboardedAt for tenants"); + } + } + /// /// Aggregates metrics for any missed days in the last 7 days, plus yesterday. /// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs index d0bd3c1a..291e3019 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs @@ -155,6 +155,44 @@ namespace AutopilotMonitor.Functions.Services return false; } + /// + /// Sends a Telegram notification when a user submits feedback via the in-app feedback bubble. + /// Best-effort — silently no-ops if the webhook URL is not configured. + /// + public async Task SendFeedbackAsync(string tenantId, string upn, string displayName, int rating, string? comment) + { + try + { + var webhookUrl = await GetWebhookUrlAsync(); + if (string.IsNullOrWhiteSpace(webhookUrl)) + { + _logger.LogDebug("Telegram webhook URL not configured — skipping feedback notification"); + return; + } + + var stars = new string('\u2605', rating) + new string('\u2606', 5 - rating); + var commentLine = string.IsNullOrWhiteSpace(comment) ? "" : $"\nComment: {comment}"; + var payload = new + { + chat_id = "-1003632442830", + text = $"New Feedback!\nRating: {stars} ({rating}/5)\nUser: {displayName} ({upn})\nTenant: {tenantId}{commentLine}" + }; + + var json = JsonConvert.SerializeObject(payload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var sent = await PostWithRetryAsync(webhookUrl, content, $"feedback:{upn}"); + + if (sent) + { + _logger.LogInformation("Telegram feedback notification sent for user {Upn}", upn); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send Telegram feedback notification for {Upn}", upn); + } + } + private async Task GetWebhookUrlAsync() { try diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 6cf94e07..2ea1412d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -242,7 +242,8 @@ namespace AutopilotMonitor.Functions.Services { "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson }, { "BootstrapTokenEnabled", config.BootstrapTokenEnabled }, { "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled }, - { "UnrestrictedMode", config.UnrestrictedMode } + { "UnrestrictedMode", config.UnrestrictedMode }, + { "OnboardedAt", config.OnboardedAt } }; return entity; @@ -303,7 +304,8 @@ namespace AutopilotMonitor.Functions.Services LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"), BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false, UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false, - UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false + UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false, + OnboardedAt = entity.GetDateTime("OnboardedAt") }; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 2d7fb703..6124b30d 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -72,6 +72,29 @@ namespace AutopilotMonitor.Shared.Models /// public int MaintenanceBlockDurationHours { get; set; } = 12; + // ===== FEEDBACK SETTINGS ===== + + /// + /// Global kill-switch for the in-app feedback prompt. + /// When false, no user sees the feedback bubble regardless of other settings. + /// Default: true. + /// + public bool FeedbackEnabled { get; set; } = true; + + /// + /// Minimum tenant age in days before users are prompted for feedback. + /// Prevents asking brand-new tenants who haven't had meaningful experience yet. + /// Default: 14 days. + /// + public int FeedbackMinTenantAgeDays { get; set; } = 14; + + /// + /// Cooldown in days after a user interacts with the feedback prompt + /// before they are prompted again. 0 = never re-prompt (single wave only). + /// Default: 60 days. + /// + public int FeedbackCooldownDays { get; set; } = 60; + // ===== DIAGNOSTICS LOG PATHS ===== /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 58f97b3f..ab53f5e0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -35,6 +35,13 @@ namespace AutopilotMonitor.Shared.Models // ===== TENANT STATUS ===== + /// + /// When this tenant was first onboarded (derived from earliest TenantAdmin AddedDate). + /// Used for feedback eligibility checks (tenant must be old enough before prompting). + /// Backfilled by the maintenance job for existing tenants; set to UtcNow for new tenants. + /// + public DateTime? OnboardedAt { get; set; } + /// /// Whether this tenant is disabled/suspended /// If true, users from this tenant cannot log in @@ -511,7 +518,8 @@ namespace AutopilotMonitor.Shared.Models PerformanceCollectorIntervalSeconds = 30, SelfDestructOnComplete = true, KeepLogFile = false, - ShowScriptOutput = true + ShowScriptOutput = true, + OnboardedAt = DateTime.UtcNow }; } } diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/components/FeedbackSection.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/components/FeedbackSection.tsx new file mode 100644 index 00000000..0c9bf6d1 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/components/FeedbackSection.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { API_BASE_URL } from "@/lib/config"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; + +interface FeedbackEntry { + upn: string; + tenantId: string; + displayName: string; + rating: number | null; + comment: string | null; + dismissed: boolean; + submitted: boolean; + interactedAt: string | null; +} + +interface FeedbackSectionProps { + getAccessToken: () => Promise; + setError: (error: string | null) => void; +} + +export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionProps) { + const [expanded, setExpanded] = useState(false); + const [loading, setLoading] = useState(false); + const [entries, setEntries] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + const entriesPerPage = 5; + + const fetchFeedback = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const response = await authenticatedFetch(`${API_BASE_URL}/api/feedback/all`, getAccessToken); + + if (!response.ok) { + throw new Error(`Failed to load feedback: ${response.statusText}`); + } + + const data = await response.json(); + // Sort by interactedAt descending (newest first) + const sorted = (data.feedback || []).sort((a: FeedbackEntry, b: FeedbackEntry) => { + const dateA = a.interactedAt ? new Date(a.interactedAt).getTime() : 0; + const dateB = b.interactedAt ? new Date(b.interactedAt).getTime() : 0; + return dateB - dateA; + }); + setEntries(sorted); + } catch (err) { + if (err instanceof TokenExpiredError) { + console.error("Session expired while fetching feedback"); + } else { + console.error("Error fetching feedback:", err); + } + setError(err instanceof Error ? err.message : "Failed to load feedback"); + } finally { + setLoading(false); + } + }, [getAccessToken, setError]); + + useEffect(() => { + if (expanded && entries.length === 0) { + fetchFeedback(); + } + }, [expanded, entries.length, fetchFeedback]); + + // Stats + const submittedEntries = entries.filter(e => e.submitted); + const dismissedEntries = entries.filter(e => e.dismissed && !e.submitted); + const avgRating = submittedEntries.length > 0 + ? (submittedEntries.reduce((sum, e) => sum + (e.rating || 0), 0) / submittedEntries.length).toFixed(1) + : "—"; + + // Pagination + const totalPages = Math.ceil(entries.length / entriesPerPage); + const paginatedEntries = entries.slice( + currentPage * entriesPerPage, + (currentPage + 1) * entriesPerPage + ); + + const formatTimeAgo = (dateStr: string | null): string => { + if (!dateStr) return "unknown"; + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + const weeks = Math.floor(days / 7); + return `${weeks}w ago`; + }; + + const renderStars = (rating: number | null) => { + if (rating == null) return null; + return ( + + {[1, 2, 3, 4, 5].map(s => ( + + + + ))} + + ); + }; + + return ( +
+ {/* Header */} + + + {/* Content */} + {expanded && ( +
+ {loading ? ( +
+
+
+ ) : entries.length === 0 ? ( +
+ No feedback received yet +
+ ) : ( + <> + {/* Stats */} +
+ + {submittedEntries.length} Submitted + + | + + {dismissedEntries.length} Dismissed + + | + + Avg {avgRating} + + +
+ + {/* Entries */} +
+ {paginatedEntries.map((entry) => ( +
+
+
+ + {entry.upn} + + {entry.submitted ? renderStars(entry.rating) : ( + dismissed + )} +
+
+ {formatTimeAgo(entry.interactedAt)} + · + + {entry.tenantId.substring(0, 8)}... + +
+
+ {entry.comment && ( +

+ "{entry.comment}" +

+ )} +
+ ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {currentPage + 1} of {totalPages} + + +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx index 4a7c3851..f2ae210c 100644 --- a/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin-configuration/page.tsx @@ -14,9 +14,10 @@ import { VersionBlockSection } from "./components/VersionBlockSection"; import { SessionExportSection } from "./components/SessionExportSection"; import { AdminConfigSettingsSection } from "./components/AdminConfigSettingsSection"; import { SessionReportsSection } from "./components/SessionReportsSection"; +import { FeedbackSection } from "./components/FeedbackSection"; import { usePageSections } from "../../hooks/usePageSections"; import { PageSectionItem } from "../../contexts/SidebarContext"; -import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon } from "../../lib/sidebarIcons"; +import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon, SparklesIcon } from "../../lib/sidebarIcons"; interface AdminConfiguration { partitionKey: string; @@ -253,6 +254,7 @@ export default function AdminConfigurationPage() { const adminSections: PageSectionItem[] = useMemo(() => [ { id: "tenant-management", label: "Tenant Management", icon: }, { id: "session-reports", label: "Session Reports", icon: }, + { id: "user-feedback", label: "User Feedback", icon: }, { id: "session-export", label: "Session Export", icon: }, { id: "global-settings", label: "Global Settings", icon: }, { id: "diagnostics-log-paths", label: "Diagnostics Log Paths", icon: }, @@ -331,6 +333,14 @@ export default function AdminConfigurationPage() { />
+ {/* User Feedback */} +
+ +
+ {/* Session Event Export */}
+ diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx index e465c879..4d92c0bd 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AgentSettingsSection.tsx @@ -349,33 +349,32 @@ export default function AgentSettingsSection({ )} + {/* Save / Reset */} +
+ + +
- - {/* Save / Reset */} -
- - -
); } diff --git a/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx b/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx new file mode 100644 index 00000000..7b793ce4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { API_BASE_URL } from "@/lib/config"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; + +type Phase = "loading" | "bubble" | "form" | "thankyou" | "hidden"; + +export default function FeedbackBubble() { + const { isAuthenticated, user, getAccessToken } = useAuth(); + const [phase, setPhase] = useState("loading"); + const [rating, setRating] = useState(0); + const [hoveredStar, setHoveredStar] = useState(0); + const [comment, setComment] = useState(""); + const [submitting, setSubmitting] = useState(false); + + // Check eligibility on mount + useEffect(() => { + if (!isAuthenticated || !user) return; + + let cancelled = false; + + const checkEligibility = async () => { + try { + const response = await authenticatedFetch( + `${API_BASE_URL}/api/feedback/status`, + getAccessToken + ); + if (cancelled) return; + + if (response.ok) { + const data = await response.json(); + if (data.eligible) { + // Show bubble after 3s delay + setTimeout(() => { + if (!cancelled) setPhase("bubble"); + }, 3000); + } else { + setPhase("hidden"); + } + } else { + setPhase("hidden"); + } + } catch { + setPhase("hidden"); + } + }; + + checkEligibility(); + return () => { cancelled = true; }; + }, [isAuthenticated, user, getAccessToken]); + + const handleDismiss = useCallback(async () => { + setPhase("hidden"); + try { + await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dismissed: true }), + }); + } catch { + // Best effort + } + }, [getAccessToken]); + + const handleSubmit = useCallback(async () => { + if (rating === 0 || submitting) return; + setSubmitting(true); + + try { + await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rating, comment: comment.trim() || null, dismissed: false }), + }); + + setPhase("thankyou"); + setTimeout(() => setPhase("hidden"), 2000); + } catch { + // Still show thank you — feedback may have been recorded + setPhase("thankyou"); + setTimeout(() => setPhase("hidden"), 2000); + } finally { + setSubmitting(false); + } + }, [rating, comment, submitting, getAccessToken]); + + // Don't render anything while loading or hidden + if (phase === "loading" || phase === "hidden") return null; + + return ( +
+ {/* Collapsed Bubble */} + {phase === "bubble" && ( +
setPhase("form")} + > + + + + Feedback + +
+ )} + + {/* Expanded Form */} + {phase === "form" && ( +
+ {/* Header */} +
+

+ How's your experience? +

+ +
+ +
+ {/* Star Rating */} +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ + {/* Comment */} +
+