mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat: fix session report download + add admin note feature
- Fix 500 error on download URL: replace GenerateSasUri() with
blobClient.Uri (SAS already embedded in service-level connection string)
- Add AdminNote field to SessionReportMetadata shared model
- Read AdminNote from Table Storage in GetAllReportsAsync
- Add UpdateAdminNoteAsync to SessionReportService
- New PATCH /api/galactic/session-reports/{reportId}/note endpoint
- Frontend: editable Admin Note textarea in report detail modal with
inline save feedback; Admin Note column in reports table with icon
- Add copy-to-clipboard buttons for Session ID, Tenant ID,
Submitted By, and Email fields in report detail modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-11
@@ -1,7 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Sas;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -84,16 +83,9 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
return notFoundResponse;
|
||||
}
|
||||
|
||||
var sasBuilder = new BlobSasBuilder
|
||||
{
|
||||
BlobContainerName = ContainerName,
|
||||
BlobName = blobName,
|
||||
Resource = "b",
|
||||
ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15)
|
||||
};
|
||||
sasBuilder.SetPermissions(BlobSasPermissions.Read);
|
||||
|
||||
var downloadUrl = blobClient.GenerateSasUri(sasBuilder).ToString();
|
||||
// The connection string uses a service-level SAS token, so the blob URI
|
||||
// already contains the access token — no separate SAS generation needed.
|
||||
var downloadUrl = blobClient.Uri.ToString();
|
||||
|
||||
_logger.LogInformation("Generated session report download URL for blob {BlobName}", blobName);
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions
|
||||
{
|
||||
/// <summary>
|
||||
/// Allows Galactic Admins to add or update an admin note on a submitted session report.
|
||||
/// </summary>
|
||||
public class UpdateSessionReportNoteFunction
|
||||
{
|
||||
private readonly ILogger<UpdateSessionReportNoteFunction> _logger;
|
||||
private readonly SessionReportService _sessionReportService;
|
||||
private readonly GalacticAdminService _galacticAdminService;
|
||||
|
||||
public UpdateSessionReportNoteFunction(
|
||||
ILogger<UpdateSessionReportNoteFunction> logger,
|
||||
SessionReportService sessionReportService,
|
||||
GalacticAdminService galacticAdminService)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionReportService = sessionReportService;
|
||||
_galacticAdminService = galacticAdminService;
|
||||
}
|
||||
|
||||
[Function("UpdateSessionReportNote")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "galactic/session-reports/{reportId}/note")] HttpRequestData req,
|
||||
string reportId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TenantHelper.IsAuthenticated(req))
|
||||
{
|
||||
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required." });
|
||||
return unauthorizedResponse;
|
||||
}
|
||||
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
|
||||
if (!isGalacticAdmin)
|
||||
{
|
||||
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied. Galactic Admin role required." });
|
||||
return forbiddenResponse;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(reportId))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "reportId is required." });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
string body = await req.ReadAsStringAsync() ?? string.Empty;
|
||||
JObject json;
|
||||
try
|
||||
{
|
||||
json = JObject.Parse(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid JSON body." });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
var adminNote = json["adminNote"]?.ToString() ?? string.Empty;
|
||||
|
||||
var updated = await _sessionReportService.UpdateAdminNoteAsync(reportId, adminNote);
|
||||
if (!updated)
|
||||
{
|
||||
var notFoundResponse = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFoundResponse.WriteAsJsonAsync(new { success = false, message = "Report not found." });
|
||||
return notFoundResponse;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Admin note updated for report {ReportId} by {User}", reportId, userIdentifier);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating admin note for report {ReportId}", reportId);
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
Email = entity.GetString("Email") ?? string.Empty,
|
||||
BlobName = entity.GetString("BlobName") ?? string.Empty,
|
||||
SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty,
|
||||
SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue
|
||||
SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
AdminNote = entity.GetString("AdminNote") ?? string.Empty
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -199,6 +200,37 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the AdminNote field for a report identified by reportId.
|
||||
/// </summary>
|
||||
public async Task<bool> UpdateAdminNoteAsync(string reportId, string adminNote)
|
||||
{
|
||||
var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.SessionReports);
|
||||
|
||||
// Find the entity by scanning for matching ReportId
|
||||
TableEntity? found = null;
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(e => e.PartitionKey == "reports"))
|
||||
{
|
||||
if (entity.GetString("ReportId") == reportId)
|
||||
{
|
||||
found = entity;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found == null)
|
||||
{
|
||||
_logger.LogWarning("UpdateAdminNote: report {ReportId} not found", reportId);
|
||||
return false;
|
||||
}
|
||||
|
||||
found["AdminNote"] = adminNote ?? string.Empty;
|
||||
await tableClient.UpsertEntityAsync(found, TableUpdateMode.Merge);
|
||||
|
||||
_logger.LogInformation("Updated AdminNote for report {ReportId}", reportId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddJsonEntry(ZipArchive archive, string name, object data)
|
||||
{
|
||||
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
|
||||
|
||||
@@ -241,5 +241,6 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string BlobName { get; set; }
|
||||
public string SubmittedBy { get; set; }
|
||||
public DateTime SubmittedAt { get; set; }
|
||||
public string AdminNote { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+150
-5
@@ -12,6 +12,7 @@ interface SessionReport {
|
||||
blobName: string;
|
||||
submittedBy: string;
|
||||
submittedAt: string;
|
||||
adminNote?: string;
|
||||
}
|
||||
|
||||
interface SessionReportsSectionProps {
|
||||
@@ -19,6 +20,34 @@ interface SessionReportsSectionProps {
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
className="ml-1.5 p-0.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionReportsSection({
|
||||
getAccessToken,
|
||||
setError,
|
||||
@@ -27,12 +56,22 @@ export function SessionReportsSection({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
|
||||
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(null);
|
||||
const [adminNoteValue, setAdminNoteValue] = useState("");
|
||||
const [savingNote, setSavingNote] = useState(false);
|
||||
const [noteSaveResult, setNoteSaveResult] = useState<"saved" | string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedReport) {
|
||||
setAdminNoteValue(selectedReport.adminNote ?? "");
|
||||
setNoteSaveResult(null);
|
||||
}
|
||||
}, [selectedReport]);
|
||||
|
||||
const handleDownload = async (blobName: string) => {
|
||||
try {
|
||||
setDownloadingBlob(blobName);
|
||||
@@ -58,6 +97,39 @@ export function SessionReportsSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAdminNote = async () => {
|
||||
if (!selectedReport) return;
|
||||
try {
|
||||
setSavingNote(true);
|
||||
setNoteSaveResult(null);
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new Error("Failed to get access token");
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/galactic/session-reports/${selectedReport.reportId}/note`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ adminNote: adminNoteValue }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to save note: ${res.statusText}`);
|
||||
|
||||
// Update local state
|
||||
const updated = { ...selectedReport, adminNote: adminNoteValue };
|
||||
setSelectedReport(updated);
|
||||
setReports(prev => prev.map(r => r.reportId === selectedReport.reportId ? updated : r));
|
||||
setNoteSaveResult("saved");
|
||||
} catch (err) {
|
||||
setNoteSaveResult(err instanceof Error ? err.message : "Failed to save note");
|
||||
} finally {
|
||||
setSavingNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -126,6 +198,7 @@ export function SessionReportsSection({
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tenant</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Submitted By</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Comment</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Admin Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@@ -150,6 +223,18 @@ export function SessionReportsSection({
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 truncate max-w-xs">
|
||||
{r.comment || <span className="text-gray-400 italic">no comment</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{r.adminNote ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4 text-indigo-500 dark:text-indigo-400 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[140px]">{r.adminNote}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-300 dark:text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -179,15 +264,24 @@ export function SessionReportsSection({
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Session ID</dt>
|
||||
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.sessionId}</dd>
|
||||
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
|
||||
{selectedReport.sessionId}
|
||||
<CopyButton value={selectedReport.sessionId} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Tenant ID</dt>
|
||||
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.tenantId}</dd>
|
||||
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
|
||||
{selectedReport.tenantId}
|
||||
<CopyButton value={selectedReport.tenantId} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Submitted By</dt>
|
||||
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.submittedBy}</dd>
|
||||
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
|
||||
{selectedReport.submittedBy}
|
||||
<CopyButton value={selectedReport.submittedBy} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Submitted At</dt>
|
||||
@@ -195,8 +289,10 @@ export function SessionReportsSection({
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400">Email</dt>
|
||||
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">
|
||||
{selectedReport.email || <span className="text-gray-400 italic">not provided</span>}
|
||||
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 flex items-center">
|
||||
{selectedReport.email
|
||||
? <><span>{selectedReport.email}</span><CopyButton value={selectedReport.email} /></>
|
||||
: <span className="text-gray-400 italic">not provided</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -211,6 +307,55 @@ export function SessionReportsSection({
|
||||
{selectedReport.blobName}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Admin Note */}
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
Admin Note
|
||||
</dt>
|
||||
<dd className="mt-1.5">
|
||||
<textarea
|
||||
value={adminNoteValue}
|
||||
onChange={e => { setAdminNoteValue(e.target.value); setNoteSaveResult(null); }}
|
||||
rows={3}
|
||||
placeholder="Add an internal note about this report..."
|
||||
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="text-xs">
|
||||
{noteSaveResult === "saved" && (
|
||||
<span className="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{noteSaveResult && noteSaveResult !== "saved" && (
|
||||
<span className="text-red-600 dark:text-red-400">{noteSaveResult}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveAdminNote}
|
||||
disabled={savingNote}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium"
|
||||
>
|
||||
{savingNote ? (
|
||||
<>
|
||||
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : "Save Note"}
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user