From 9d9e626cfed3570ed3e05fbf98472d5355852ef8 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 18:41:43 -0400 Subject: [PATCH] feature/IO-3587-Commision-Cut-Clean - Split localStack client into smaller files / seperated by concerns --- _reference/localEmailViewer/README.md | 8 + _reference/localEmailViewer/index.js | 4525 +---------------- _reference/localEmailViewer/package.json | 2 +- .../localEmailViewer/public/client-app.js | 3154 ++++++++++++ _reference/localEmailViewer/server/config.js | 45 + .../server/localstack-service.js | 845 +++ _reference/localEmailViewer/server/page.js | 495 ++ 7 files changed, 4592 insertions(+), 4482 deletions(-) create mode 100644 _reference/localEmailViewer/public/client-app.js create mode 100644 _reference/localEmailViewer/server/config.js create mode 100644 _reference/localEmailViewer/server/localstack-service.js create mode 100644 _reference/localEmailViewer/server/page.js diff --git a/_reference/localEmailViewer/README.md b/_reference/localEmailViewer/README.md index 019d6f946..cbfe9e555 100644 --- a/_reference/localEmailViewer/README.md +++ b/_reference/localEmailViewer/README.md @@ -32,6 +32,14 @@ Features: - Shared LocalStack service health strip plus a reset action for clearing saved viewer state - Compact single-page UI for switching between the local stack tools you use most +Code layout: + +- `index.js`: small Express bootstrap and route registration +- `server/config.js`: LocalStack endpoints, defaults, and AWS client setup +- `server/localstack-service.js`: SES, Logs, Secrets, and S3 data loading helpers +- `server/page.js`: server-rendered HTML shell, CSS, and client config payload +- `public/client-app.js`: browser-side UI state, rendering, refresh logic, and interactions + Optional environment variables: ```shell diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 64b72fbd7..67df19055 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -1,59 +1,41 @@ import express from "express"; -import fetch from "node-fetch"; +import { readFileSync } from "node:fs"; import { - CloudWatchLogsClient, - DescribeLogGroupsCommand, - DescribeLogStreamsCommand, - FilterLogEventsCommand -} from "@aws-sdk/client-cloudwatch-logs"; -import { GetSecretValueCommand, ListSecretsCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; + CLOUDWATCH_DEFAULT_LIMIT, + CLOUDWATCH_DEFAULT_WINDOW_MS, + CLOUDWATCH_ENDPOINT, + CLOUDWATCH_REGION, + DEFAULT_REFRESH_MS, + PORT, + S3_ENDPOINT, + S3_REGION, + SES_ENDPOINT, + SECRETS_ENDPOINT, + SECRETS_REGION +} from "./server/config.js"; +import { getClientConfig, renderHtml } from "./server/page.js"; import { - GetObjectCommand, - HeadObjectCommand, - ListBucketsCommand, - ListObjectsV2Command, - S3Client -} from "@aws-sdk/client-s3"; -import { simpleParser } from "mailparser"; + buildAttachmentDisposition, + buildInlineDisposition, + clampNumber, + findSesMessageById, + loadLogEvents, + loadLogGroups, + loadLogStreams, + loadMessageAttachment, + loadMessages, + loadS3Buckets, + loadS3ObjectDownload, + loadS3ObjectPreview, + loadS3Objects, + loadSecretValue, + loadSecrets, + loadServiceHealthSummary +} from "./server/localstack-service.js"; const app = express(); - -const PORT = Number(process.env.PORT || 3334); -const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses"; -const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000); -const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000); -const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566"; -const CLOUDWATCH_REGION = process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1"; -const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development"; -const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000); -const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200); -const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT; -const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION; -const S3_ENDPOINT = process.env.S3_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT; -const S3_REGION = process.env.S3_VIEWER_REGION || CLOUDWATCH_REGION; -const S3_DEFAULT_BUCKET = process.env.S3_VIEWER_BUCKET || ""; -const S3_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_PREVIEW_BYTES || 256 * 1024); -const S3_IMAGE_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_IMAGE_PREVIEW_BYTES || 1024 * 1024); -const LOCALSTACK_CREDENTIALS = { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test", - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test" -}; -const cloudWatchLogsClient = new CloudWatchLogsClient({ - region: CLOUDWATCH_REGION, - endpoint: CLOUDWATCH_ENDPOINT, - credentials: LOCALSTACK_CREDENTIALS -}); -const secretsManagerClient = new SecretsManagerClient({ - region: SECRETS_REGION, - endpoint: SECRETS_ENDPOINT, - credentials: LOCALSTACK_CREDENTIALS -}); -const s3Client = new S3Client({ - region: S3_REGION, - endpoint: S3_ENDPOINT, - credentials: LOCALSTACK_CREDENTIALS, - forcePathStyle: true -}); +const CLIENT_APP_PATH = new URL("./public/client-app.js", import.meta.url); +const CLIENT_APP_SOURCE = readFileSync(CLIENT_APP_PATH, "utf8"); app.use((req, res, next) => { res.set("Cache-Control", "no-store"); @@ -65,7 +47,7 @@ app.get("/", (req, res) => { }); app.get("/app.js", (req, res) => { - res.type("application/javascript").send(`(${clientApp.toString()})(${JSON.stringify(getClientConfig())});`); + res.type("application/javascript").send(`${CLIENT_APP_SOURCE}\n\nclientApp(${JSON.stringify(getClientConfig())});\n`); }); app.get("/health", (req, res) => { @@ -133,27 +115,17 @@ app.get("/api/messages/:id/attachments/:index", async (req, res) => { return; } - const parsed = await parseSesMessageById(req.params.id); - - if (!parsed) { - res.status(404).type("text/plain").send("Message not found"); - return; - } - - const attachment = parsed.attachments?.[attachmentIndex]; + const attachment = await loadMessageAttachment(req.params.id, attachmentIndex); if (!attachment) { res.status(404).type("text/plain").send("Attachment not found"); return; } - const filename = resolveAttachmentFilename(attachment, attachmentIndex); - const content = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || ""); - - res.setHeader("Content-Type", attachment.contentType || "application/octet-stream"); - res.setHeader("Content-Disposition", buildAttachmentDisposition(filename)); - res.setHeader("Content-Length", String(content.length)); - res.send(content); + res.setHeader("Content-Type", attachment.contentType); + res.setHeader("Content-Disposition", buildAttachmentDisposition(attachment.filename)); + res.setHeader("Content-Length", String(attachment.content.length)); + res.send(attachment.content); } catch (error) { console.error("Error downloading attachment:", error); res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`); @@ -166,7 +138,6 @@ app.get("/api/logs/groups", async (req, res) => { res.json({ endpoint: CLOUDWATCH_ENDPOINT, region: CLOUDWATCH_REGION, - defaultGroup: CLOUDWATCH_DEFAULT_GROUP, groups }); } catch (error) { @@ -342,22 +313,15 @@ app.get("/api/s3/download", async (req, res) => { return; } - const response = await s3Client.send( - new GetObjectCommand({ - Bucket: bucket, - Key: key - }) - ); - const content = Buffer.from(await response.Body.transformToByteArray()); - const filename = basenameFromKey(key); + const object = await loadS3ObjectDownload({ bucket, key }); - res.setHeader("Content-Type", response.ContentType || guessObjectContentType(key)); + res.setHeader("Content-Type", object.contentType); res.setHeader( "Content-Disposition", - inline ? buildInlineDisposition(filename) : buildAttachmentDisposition(filename) + inline ? buildInlineDisposition(object.filename) : buildAttachmentDisposition(object.filename) ); - res.setHeader("Content-Length", String(content.length)); - res.send(content); + res.setHeader("Content-Length", String(object.content.length)); + res.send(object.content); } catch (error) { if (error?.name === "NoSuchKey" || error?.name === "NotFound") { res.status(404).type("text/plain").send("Object not found"); @@ -369,4407 +333,6 @@ app.get("/api/s3/download", async (req, res) => { } }); -async function loadMessages() { - const startedAt = Date.now(); - const sesMessages = await fetchSesMessages(); - const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index))); - - messages.sort((left, right) => { - if ((right.timestampMs || 0) !== (left.timestampMs || 0)) { - return (right.timestampMs || 0) - (left.timestampMs || 0); - } - - return right.index - left.index; - }); - - return { - endpoint: SES_ENDPOINT, - fetchedAt: new Date().toISOString(), - fetchDurationMs: Date.now() - startedAt, - totalMessages: messages.length, - parseErrors: messages.filter((message) => Boolean(message.parseError)).length, - latestMessageTimestamp: messages[0]?.timestamp || "", - messages - }; -} - -async function fetchSesMessages() { - const response = await fetch(SES_ENDPOINT, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) - }); - - if (!response.ok) { - throw new Error(`SES endpoint responded with ${response.status}`); - } - - const data = await response.json(); - return Array.isArray(data.messages) ? data.messages : []; -} - -async function loadLogGroups() { - const groups = []; - let nextToken; - let pageCount = 0; - - do { - const response = await cloudWatchLogsClient.send( - new DescribeLogGroupsCommand({ - nextToken, - limit: 50 - }) - ); - - groups.push( - ...(response.logGroups || []).map((group) => ({ - name: group.logGroupName || "", - arn: group.arn || "", - storedBytes: group.storedBytes || 0, - retentionInDays: group.retentionInDays || 0, - creationTime: group.creationTime || 0 - })) - ); - - nextToken = response.nextToken; - pageCount += 1; - } while (nextToken && pageCount < 10); - - return groups.sort((left, right) => left.name.localeCompare(right.name)); -} - -async function loadLogStreams(logGroupName) { - const streams = []; - let nextToken; - let pageCount = 0; - - do { - const response = await cloudWatchLogsClient.send( - new DescribeLogStreamsCommand({ - logGroupName, - descending: true, - orderBy: "LastEventTime", - nextToken, - limit: 50 - }) - ); - - streams.push( - ...(response.logStreams || []).map((stream) => ({ - name: stream.logStreamName || "", - arn: stream.arn || "", - lastEventTimestamp: stream.lastEventTimestamp || 0, - lastIngestionTime: stream.lastIngestionTime || 0, - storedBytes: stream.storedBytes || 0 - })) - ); - - nextToken = response.nextToken; - pageCount += 1; - } while (nextToken && pageCount < 6 && streams.length < 250); - - return streams; -} - -async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) { - const startedAt = Date.now(); - const eventMap = new Map(); - const startTime = Date.now() - windowMs; - let nextToken; - let previousToken = ""; - let pageCount = 0; - let searchedLogStreams = 0; - - do { - const response = await cloudWatchLogsClient.send( - new FilterLogEventsCommand({ - logGroupName, - logStreamNames: logStreamName ? [logStreamName] : undefined, - startTime, - endTime: Date.now(), - limit, - nextToken - }) - ); - - for (const event of response.events || []) { - const id = - event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`; - - if (!eventMap.has(id)) { - const message = String(event.message || "").trim(); - eventMap.set(id, { - id, - timestamp: event.timestamp || 0, - ingestionTime: event.ingestionTime || 0, - logStreamName: event.logStreamName || "", - message, - preview: buildLogPreview(message) - }); - } - } - - searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length); - previousToken = nextToken || ""; - nextToken = response.nextToken; - pageCount += 1; - } while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit); - - const events = [...eventMap.values()] - .sort((left, right) => { - if ((right.timestamp || 0) !== (left.timestamp || 0)) { - return (right.timestamp || 0) - (left.timestamp || 0); - } - - return left.logStreamName.localeCompare(right.logStreamName); - }) - .slice(0, limit); - - return { - endpoint: CLOUDWATCH_ENDPOINT, - region: CLOUDWATCH_REGION, - logGroupName, - logStreamName, - fetchDurationMs: Date.now() - startedAt, - latestTimestamp: events[0]?.timestamp || 0, - searchedLogStreams, - totalEvents: events.length, - events - }; -} - -async function loadSecrets() { - const startedAt = Date.now(); - const secrets = []; - let nextToken; - let pageCount = 0; - - do { - const response = await secretsManagerClient.send( - new ListSecretsCommand({ - NextToken: nextToken, - MaxResults: 50 - }) - ); - - secrets.push( - ...(response.SecretList || []).map((secret, index) => ({ - id: secret.ARN || secret.Name || `secret-${index}`, - name: secret.Name || "Unnamed secret", - arn: secret.ARN || "", - description: secret.Description || "", - createdDate: normalizeTimestamp(secret.CreatedDate), - lastChangedDate: normalizeTimestamp(secret.LastChangedDate), - lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate), - deletedDate: normalizeTimestamp(secret.DeletedDate), - primaryRegion: secret.PrimaryRegion || "", - owningService: secret.OwningService || "", - rotationEnabled: Boolean(secret.RotationEnabled), - versionCount: Object.keys(secret.SecretVersionsToStages || {}).length, - tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0, - tags: (secret.Tags || []) - .map((tag) => ({ - key: tag.Key || "", - value: tag.Value || "" - })) - .filter((tag) => tag.key || tag.value) - })) - ); - - nextToken = response.NextToken; - pageCount += 1; - } while (nextToken && pageCount < 10 && secrets.length < 500); - - secrets.sort((left, right) => { - const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0; - const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0; - - if (rightTime !== leftTime) { - return rightTime - leftTime; - } - - return left.name.localeCompare(right.name); - }); - - return { - endpoint: SECRETS_ENDPOINT, - region: SECRETS_REGION, - fetchedAt: new Date().toISOString(), - fetchDurationMs: Date.now() - startedAt, - totalSecrets: secrets.length, - latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "", - secrets - }; -} - -async function loadSecretValue(secretId) { - const startedAt = Date.now(); - const response = await secretsManagerClient.send( - new GetSecretValueCommand({ - SecretId: secretId - }) - ); - - const secretBinary = response.SecretBinary - ? typeof response.SecretBinary === "string" - ? response.SecretBinary - : Buffer.from(response.SecretBinary).toString("base64") - : ""; - - return { - endpoint: SECRETS_ENDPOINT, - region: SECRETS_REGION, - fetchDurationMs: Date.now() - startedAt, - id: secretId, - name: response.Name || "", - arn: response.ARN || "", - versionId: response.VersionId || "", - versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [], - createdDate: normalizeTimestamp(response.CreatedDate), - secretString: typeof response.SecretString === "string" ? response.SecretString : "", - secretBinary - }; -} - -async function loadS3Buckets() { - const startedAt = Date.now(); - const response = await s3Client.send(new ListBucketsCommand({})); - const buckets = (response.Buckets || []) - .map((bucket) => ({ - name: bucket.Name || "", - creationDate: normalizeTimestamp(bucket.CreationDate) - })) - .filter((bucket) => bucket.name) - .sort((left, right) => left.name.localeCompare(right.name)); - - return { - endpoint: S3_ENDPOINT, - region: S3_REGION, - fetchedAt: new Date().toISOString(), - fetchDurationMs: Date.now() - startedAt, - totalBuckets: buckets.length, - buckets - }; -} - -async function loadS3Objects({ bucket, prefix }) { - const startedAt = Date.now(); - const objects = []; - let continuationToken; - let pageCount = 0; - - do { - const response = await s3Client.send( - new ListObjectsV2Command({ - Bucket: bucket, - Prefix: prefix || undefined, - ContinuationToken: continuationToken, - MaxKeys: 200 - }) - ); - - objects.push( - ...(response.Contents || []).map((object, index) => ({ - id: `${bucket}::${object.Key || index}`, - bucket, - key: object.Key || "", - size: object.Size || 0, - lastModified: normalizeTimestamp(object.LastModified), - etag: String(object.ETag || "").replace(/^"|"$/g, ""), - storageClass: object.StorageClass || "STANDARD" - })) - ); - - continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; - pageCount += 1; - } while (continuationToken && pageCount < 10 && objects.length < 1000); - - objects.sort((left, right) => { - const leftTime = Date.parse(left.lastModified || 0) || 0; - const rightTime = Date.parse(right.lastModified || 0) || 0; - - if (rightTime !== leftTime) { - return rightTime - leftTime; - } - - return left.key.localeCompare(right.key); - }); - - return { - endpoint: S3_ENDPOINT, - region: S3_REGION, - bucket, - prefix, - fetchedAt: new Date().toISOString(), - fetchDurationMs: Date.now() - startedAt, - totalObjects: objects.length, - latestTimestamp: objects[0]?.lastModified || "", - objects - }; -} - -async function loadS3ObjectPreview({ bucket, key }) { - const startedAt = Date.now(); - const head = await s3Client.send( - new HeadObjectCommand({ - Bucket: bucket, - Key: key - }) - ); - - const contentType = head.ContentType || guessObjectContentType(key); - const contentLength = Number(head.ContentLength || 0); - const previewType = resolveS3PreviewType(contentType, key); - const result = { - endpoint: S3_ENDPOINT, - region: S3_REGION, - bucket, - key, - fetchDurationMs: 0, - contentType, - contentLength, - etag: String(head.ETag || "").replace(/^"|"$/g, ""), - lastModified: normalizeTimestamp(head.LastModified), - metadata: head.Metadata || {}, - previewType, - previewText: "", - imageDataUrl: "", - truncated: false - }; - - const shouldLoadTextPreview = previewType === "json" || previewType === "text" || previewType === "html"; - const shouldLoadImagePreview = - previewType === "image" && contentLength > 0 && contentLength <= S3_IMAGE_PREVIEW_MAX_BYTES; - - if ((shouldLoadTextPreview || shouldLoadImagePreview) && contentLength > 0) { - const previewBytes = Math.max(1, Math.min(contentLength || S3_PREVIEW_MAX_BYTES, S3_PREVIEW_MAX_BYTES)); - const response = await s3Client.send( - new GetObjectCommand({ - Bucket: bucket, - Key: key, - Range: `bytes=0-${previewBytes - 1}` - }) - ); - const content = Buffer.from(await response.Body.transformToByteArray()); - result.truncated = contentLength > content.length; - - if (shouldLoadImagePreview) { - result.imageDataUrl = `data:${contentType};base64,${content.toString("base64")}`; - } else { - result.previewText = content.toString("utf8"); - } - } - - result.fetchDurationMs = Date.now() - startedAt; - return result; -} - -async function loadServiceHealthSummary() { - const startedAt = Date.now(); - const [sesResult, logsResult, secretsResult, s3Result] = await Promise.allSettled([ - fetchSesMessages(), - loadLogGroups(), - loadSecrets(), - loadS3Buckets() - ]); - - return { - fetchedAt: new Date().toISOString(), - fetchDurationMs: Date.now() - startedAt, - services: { - emails: summarizeHealthResult({ - icon: "โœ‰๏ธ", - panel: "emails", - label: "SES Emails", - result: sesResult, - count: sesResult.status === "fulfilled" ? sesResult.value.length : 0, - detail: SES_ENDPOINT, - noun: "email" - }), - logs: summarizeHealthResult({ - icon: "๐Ÿ“œ", - panel: "logs", - label: "CloudWatch Logs", - result: logsResult, - count: logsResult.status === "fulfilled" ? logsResult.value.length : 0, - detail: `${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`, - noun: "group" - }), - secrets: summarizeHealthResult({ - icon: "๐Ÿ”", - panel: "secrets", - label: "Secrets Manager", - result: secretsResult, - count: secretsResult.status === "fulfilled" ? secretsResult.value.totalSecrets : 0, - detail: `${SECRETS_ENDPOINT} (${SECRETS_REGION})`, - noun: "secret" - }), - s3: summarizeHealthResult({ - icon: "๐Ÿชฃ", - panel: "s3", - label: "S3 Explorer", - result: s3Result, - count: s3Result.status === "fulfilled" ? s3Result.value.totalBuckets : 0, - detail: `${S3_ENDPOINT} (${S3_REGION})`, - noun: "bucket" - }) - } - }; -} - -async function findSesMessageById(id) { - const messages = await fetchSesMessages(); - return messages.find((message, index) => resolveMessageId(message, index) === id) || null; -} - -async function parseSesMessageById(id) { - const message = await findSesMessageById(id); - - if (!message) { - return null; - } - - return simpleParser(message.RawData || ""); -} - -async function toMessageViewModel(message, index) { - const id = resolveMessageId(message, index); - - try { - const parsed = await simpleParser(message.RawData || ""); - const textContent = normalizeText(parsed.text || ""); - const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || ""); - const timestamp = normalizeTimestamp(message.Timestamp || parsed.date); - - return { - id, - index, - from: formatAddressList(parsed.from) || message.Source || "Unknown sender", - to: formatAddressList(parsed.to) || "No To Address", - replyTo: formatAddressList(parsed.replyTo), - subject: parsed.subject || "No Subject", - region: message.Region || "", - timestamp, - timestampMs: timestamp ? Date.parse(timestamp) : 0, - messageId: parsed.messageId || "", - rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), - attachmentCount: parsed.attachments.length, - attachments: parsed.attachments.map((attachment, attachmentIndex) => ({ - index: attachmentIndex, - filename: resolveAttachmentFilename(attachment, attachmentIndex), - contentType: attachment.contentType || "application/octet-stream", - size: attachment.size || 0 - })), - preview: buildPreview(textContent, renderedHtml), - textContent, - renderedHtml, - hasHtml: Boolean(renderedHtml), - parseError: "" - }; - } catch (error) { - return { - id, - index, - from: message.Source || "Unknown sender", - to: "Unknown recipient", - replyTo: "", - subject: "Unable to parse message", - region: message.Region || "", - timestamp: normalizeTimestamp(message.Timestamp), - timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0, - messageId: "", - rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), - attachmentCount: 0, - attachments: [], - preview: "This message could not be parsed. Open the raw view to inspect the MIME source.", - textContent: "", - renderedHtml: "", - hasHtml: false, - parseError: error.message - }; - } -} - -function resolveMessageId(message, index = 0) { - return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`; -} - -function resolveAttachmentFilename(attachment, index = 0) { - if (attachment?.filename) { - return attachment.filename; - } - - return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`; -} - -function attachmentExtension(contentType) { - const normalized = String(contentType || "") - .split(";")[0] - .trim() - .toLowerCase(); - - return ( - { - "application/json": ".json", - "application/pdf": ".pdf", - "application/zip": ".zip", - "image/gif": ".gif", - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "text/calendar": ".ics", - "text/csv": ".csv", - "text/html": ".html", - "text/plain": ".txt" - }[normalized] || "" - ); -} - -function buildAttachmentDisposition(filename) { - const fallback = String(filename || "attachment") - .replace(/[^\x20-\x7e]/g, "_") - .replace(/["\\]/g, "_"); - - return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`; -} - -function buildInlineDisposition(filename) { - const fallback = String(filename || "file") - .replace(/[^\x20-\x7e]/g, "_") - .replace(/["\\]/g, "_"); - - return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "file")}`; -} - -function basenameFromKey(key) { - const value = String(key || ""); - const parts = value.split("/").filter(Boolean); - return parts[parts.length - 1] || "file"; -} - -function guessObjectContentType(key) { - const normalizedKey = String(key || "").toLowerCase(); - - if (normalizedKey.endsWith(".json")) { - return "application/json"; - } - - if (normalizedKey.endsWith(".csv")) { - return "text/csv"; - } - - if (normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) { - return "text/html"; - } - - if (normalizedKey.endsWith(".txt") || normalizedKey.endsWith(".log") || normalizedKey.endsWith(".md")) { - return "text/plain"; - } - - if (normalizedKey.endsWith(".png")) { - return "image/png"; - } - - if (normalizedKey.endsWith(".jpg") || normalizedKey.endsWith(".jpeg")) { - return "image/jpeg"; - } - - if (normalizedKey.endsWith(".gif")) { - return "image/gif"; - } - - if (normalizedKey.endsWith(".webp")) { - return "image/webp"; - } - - if (normalizedKey.endsWith(".svg")) { - return "image/svg+xml"; - } - - if (normalizedKey.endsWith(".pdf")) { - return "application/pdf"; - } - - return "application/octet-stream"; -} - -function resolveS3PreviewType(contentType, key) { - const normalizedType = String(contentType || "").toLowerCase(); - const normalizedKey = String(key || "").toLowerCase(); - - if (normalizedType.includes("json") || normalizedKey.endsWith(".json")) { - return "json"; - } - - if (normalizedType.startsWith("image/")) { - return "image"; - } - - if (normalizedType.includes("html") || normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) { - return "html"; - } - - if ( - normalizedType.startsWith("text/") || - [".txt", ".log", ".csv", ".xml", ".yml", ".yaml", ".md"].some((extension) => normalizedKey.endsWith(extension)) - ) { - return "text"; - } - - return "binary"; -} - -function summarizeHealthResult({ icon, panel, label, result, count, detail, noun }) { - if (result.status === "fulfilled") { - return { - ok: true, - icon, - panel, - label, - count, - summary: `${count} ${noun}${count === 1 ? "" : "s"}`, - detail - }; - } - - return { - ok: false, - icon, - panel, - label, - count: 0, - summary: "Needs attention", - detail: result.reason?.message || detail - }; -} - -function normalizeTimestamp(value) { - if (!value) { - return ""; - } - - const date = value instanceof Date ? value : new Date(value); - return Number.isNaN(date.getTime()) ? "" : date.toISOString(); -} - -function normalizeText(value) { - return String(value || "") - .replace(/\r\n/g, "\n") - .trim(); -} - -function buildPreview(textContent, renderedHtml) { - const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim(); - - if (!source) { - return "No message preview available."; - } - - return source.length > 220 ? `${source.slice(0, 217)}...` : source; -} - -function buildLogPreview(message) { - const source = String(message || "") - .replace(/\s+/g, " ") - .trim(); - - if (!source) { - return "No log preview available."; - } - - return source.length > 220 ? `${source.slice(0, 217)}...` : source; -} - -function clampNumber(value, fallback, min, max) { - const parsed = Number(value); - - if (!Number.isFinite(parsed)) { - return fallback; - } - - return Math.min(Math.max(parsed, min), max); -} - -function buildRenderedHtml(html) { - if (!html) { - return ""; - } - - const value = String(html); - const hasDocument = /]/i.test(value) || / - - - - - - - - ${value} -`; -} - -function stripTags(value) { - return String(value || "") - .replace(//gi, " ") - .replace(//gi, " ") - .replace(/<[^>]+>/g, " "); -} - -function formatAddressList(addresses) { - if (!addresses?.value?.length) { - return ""; - } - - return addresses.value - .map(({ name, address }) => { - if (name && address) { - return `${name} <${address}>`; - } - - return address || name || ""; - }) - .filter(Boolean) - .join(", "); -} - -function getClientConfig() { - return { - defaultRefreshMs: DEFAULT_REFRESH_MS, - endpoint: SES_ENDPOINT, - cloudWatchEndpoint: CLOUDWATCH_ENDPOINT, - cloudWatchRegion: CLOUDWATCH_REGION, - secretsEndpoint: SECRETS_ENDPOINT, - secretsRegion: SECRETS_REGION, - s3Endpoint: S3_ENDPOINT, - s3Region: S3_REGION, - defaultS3Bucket: S3_DEFAULT_BUCKET, - defaultLogGroup: CLOUDWATCH_DEFAULT_GROUP, - defaultLogWindowMs: CLOUDWATCH_DEFAULT_WINDOW_MS, - defaultLogLimit: CLOUDWATCH_DEFAULT_LIMIT - }; -} - -function renderHtml() { - return ` - - - - - LocalStack Inspector - - - -
-
-
-
-

LocalStack Toolbox

-

Inspector

-
-
-
- - -
-
-
- Stack -
- -
-
-
- -
-
-
- - - - Waiting for first refresh... -
-
- - - - -
-
- -
-
Total00 visible
-
New0New since last refresh
-
NewestNo messagesNot refreshed yet
-
FetchIdleEndpoint: ${escapeHtml(SES_ENDPOINT)}
-
- -
-
- - -
-
- -
-
-
-
- - - - - - -
- - - -`; -} - -function renderStyles() { - return ` - :root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--log-shell:linear-gradient(180deg,rgba(239,246,255,.98),rgba(248,251,255,.99));--log-body:#f8fbff;--secret-shell:linear-gradient(180deg,rgba(239,251,246,.98),rgba(247,253,249,.99));--secret-body:#f6fcf8;--bucket-shell:linear-gradient(180deg,rgba(255,249,232,.98),rgba(255,252,243,.99));--bucket-body:#fffcf2;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--secret-line:rgba(31,143,101,.2);--bucket-line:rgba(181,137,37,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--secret:#1f8f65;--secret-soft:rgba(31,143,101,.1);--bucket:#9d6b00;--bucket-soft:rgba(181,137,37,.12);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);--secret-shadow:0 16px 32px rgba(31,143,101,.12);--bucket-shadow:0 16px 32px rgba(181,137,37,.12);} - *{box-sizing:border-box} - html,body{margin:0;height:100%;overflow:hidden} - body{color-scheme:light;background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif;transition:background-color .18s ease,color .18s ease} - button,input,select,textarea{font:inherit} - button{cursor:pointer} - .page{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;max-width:1360px;height:100vh;height:100dvh;margin:0 auto;padding:14px;overflow:hidden} - .hero{display:block;margin-bottom:0} - .heroShell,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)} - .card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)} - .heroShell,.toolControls{border-radius:18px} - .heroShell{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px} - .toolControls{padding:12px} - .heroIdentity{display:grid;gap:3px;min-width:0} - .eyebrow{margin:0 0 4px;color:var(--accent);font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase} - h1{margin:0;font-size:clamp(1.8rem,3.6vw,2.85rem);line-height:.96;letter-spacing:-.05em} - .lede{margin:8px 0 0;max-width:54ch;color:var(--muted);font-size:.92rem} - .heroTopRow{display:flex;flex:1 1 360px;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} - .heroActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} - .heroStatusRow{display:flex;flex:1 1 100%;flex-wrap:wrap;gap:8px;align-items:center} - .heroStatusLabel{color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase} - .helper{margin:0;color:var(--muted);font-size:.89rem} - .healthStrip{display:flex;flex:1 1 520px;flex-wrap:wrap;gap:6px;align-items:center;min-width:0} - .healthBadge{display:inline-flex;align-items:center;gap:8px;min-height:30px;max-width:100%;padding:0 10px;border-radius:999px;border:1px solid rgba(31,41,51,.1);background:rgba(255,255,255,.78);box-shadow:0 8px 18px rgba(15,23,42,.06);text-align:left;transition:transform .12s ease,background-color .12s ease,border-color .12s ease,box-shadow .12s ease} - .healthBadgeName{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:800;white-space:nowrap} - .healthBadgeSummary{min-width:0;overflow:hidden;color:var(--muted);font-size:.78rem;font-weight:700;text-overflow:ellipsis;white-space:nowrap} - .healthBadge.ok{border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.1)} - .healthBadge.bad{border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.1)} - .healthBadge.warn{border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.1)} - .healthBadge.active{border-color:rgba(207,109,60,.28);background:rgba(207,109,60,.16);box-shadow:0 10px 24px rgba(207,109,60,.12)} - .healthBadge.active .healthBadgeName,.healthBadge.active .healthBadgeSummary{color:var(--ink)} - .healthRefreshButton{flex:0 0 auto;padding:0 10px} - .primary,.ghost,.mini,.tab{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} - .themeToggle{white-space:nowrap} - .workspacePanel{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:6px;min-height:0} - .workspacePanel[hidden]{display:none} - .toolControls{display:grid;gap:8px} - .contentPane{height:100%;min-height:0;overflow:auto;scroll-behavior:smooth;padding-right:4px} - .contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px} - .paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px} - .paneTopButton{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:999px;border:1px solid rgba(255,255,255,.32);background:rgba(31,41,51,.42);color:#fff;font-size:1.1rem;line-height:1;backdrop-filter:blur(12px);box-shadow:0 10px 24px rgba(31,41,51,.18);opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none;transition:opacity .16s ease,transform .16s ease,background-color .12s ease;z-index:6} - .paneTopButton.visible{opacity:.78;transform:translateY(0);visibility:visible;pointer-events:auto} - .paneTopButton.visible:hover{opacity:1;background:rgba(31,41,51,.62);transform:translateY(-1px)} - .row{display:flex;flex-wrap:wrap;gap:6px;align-items:center} - .primary,.ghost{min-height:34px;padding:0 12px;font-weight:700} - .mini,.tab{min-height:28px;padding:0 10px;font-weight:600} - .primary{background:var(--accent);color:#fff7f2} - .ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)} - .tab{background:transparent;color:var(--muted)} - .tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)} - .primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)} - .chip{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.88rem} - .chip input{margin:0;accent-color:var(--accent)} - .chip select{border:none;background:transparent;outline:none;color:var(--ink)} - .search{flex:1 1 260px;min-height:36px;padding:0 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none} - .searchCompact{flex:1 1 220px} - .status{display:inline-flex;align-items:center;min-height:32px;padding:0 11px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.86rem;font-weight:600} - .status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)} - .status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)} - .status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)} - .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:0} - .stat{border-radius:16px;padding:10px 12px} - .stat span{display:block;margin-bottom:4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} - .stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em} - .stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em} - .stat small{display:block;margin-top:4px;color:var(--muted);font-size:.82rem} - .banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)} - .banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)} - .list{display:grid;gap:12px;align-content:start} - .logList{display:grid;gap:10px;align-content:start;width:100%} - .card{overflow:hidden;border-radius:16px} - .card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)} - .summary{list-style:none;display:grid;gap:7px;padding:12px 14px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))} - .summary::-webkit-details-marker{display:none} - .top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center} - .top{justify-content:space-between} - .head{min-width:0;flex:1 1 320px} - .head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word} - .meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word} - .time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700} - .time{background:rgba(31,41,51,.06)} - .tag{background:var(--accent-soft);color:#8d5632} - .tag.new{background:rgba(31,143,101,.1);color:var(--ok)} - .tag.bad{background:rgba(179,58,58,.1);color:var(--bad)} - .preview{margin:0;color:#324150;font-size:.9rem} - .body{display:grid;gap:10px;padding:10px 14px 14px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)} - .toolbar{justify-content:space-between;align-items:center} - .tabs{display:inline-flex;gap:4px;padding:3px;border-radius:999px;background:rgba(207,109,60,.08)} - .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px} - .metaCard{padding:9px 11px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)} - .metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} - .metaCard dd{margin:0;word-break:break-word} - .attachments{gap:6px} - .attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem} - .attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} - .attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)} - .panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff} - .logEvent{width:100%;overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)} - .secretCard{background:var(--secret-shell);border:1px solid var(--secret-line);box-shadow:var(--secret-shadow)} - .s3Card{background:var(--bucket-shell);border:1px solid var(--bucket-line);box-shadow:var(--bucket-shadow)} - .logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer} - .logSummary::-webkit-details-marker{display:none} - .secretSummary{background:linear-gradient(180deg,rgba(244,253,248,.9),rgba(236,249,242,.96))} - .s3Summary{background:linear-gradient(180deg,rgba(255,251,238,.92),rgba(255,246,223,.98))} - .logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center} - .logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center} - .logSummaryActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} - .logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} - .secretTag{background:var(--secret-soft);color:var(--secret);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} - .bucketTag{background:var(--bucket-soft);color:var(--bucket);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} - .logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} - .logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)} - .secretBody{border-top-color:rgba(31,143,101,.14);background:var(--secret-body)} - .s3Body{border-top-color:rgba(181,137,37,.18);background:var(--bucket-body)} - .logCopyButton{box-shadow:none} - .logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff} - .secretValuePanel{display:grid;gap:10px} - .secretValuePanel pre{border-radius:12px;border:1px solid rgba(31,143,101,.14);padding:12px;background:linear-gradient(180deg,rgba(31,143,101,.04),transparent 140px),#fff} - .s3PreviewPanel{display:grid;gap:10px} - .s3PreviewImage{max-width:min(100%,640px);border-radius:12px;border:1px solid rgba(181,137,37,.16);background:#fff} - .logBody.wrapOff pre{white-space:pre;word-break:normal} - .tag.levelError{background:rgba(179,58,58,.12);color:var(--bad)} - .tag.levelWarn{background:rgba(157,95,0,.12);color:var(--warn)} - .tag.levelInfo{background:rgba(48,113,169,.12);color:var(--info)} - .tag.levelDebug{background:rgba(96,112,128,.12);color:var(--muted)} - .jsonSyntax .jsonKey{color:#b55f2d} - .jsonSyntax .jsonString{color:#1f8f65} - .jsonSyntax .jsonNumber{color:#2f6ea9} - .jsonSyntax .jsonBoolean{color:#9d5f00} - .jsonSyntax .jsonNull{color:#b33a3a} - iframe{width:100%;min-height:560px;border:none;background:#fff} - pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff} - .placeholder,.inlineError{padding:12px} - .inlineError{color:var(--bad)} - body[data-theme="dark"]{color-scheme:dark;background:radial-gradient(circle at top left,rgba(207,109,60,.12),transparent 28%),radial-gradient(circle at top right,rgba(48,113,169,.12),transparent 26%),linear-gradient(180deg,#10161d,#17202a)} - body[data-theme="dark"] .heroShell, - body[data-theme="dark"] .toolControls, - body[data-theme="dark"] .stat{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.16);box-shadow:0 14px 30px rgba(0,0,0,.32)} - body[data-theme="dark"] .card{background:linear-gradient(180deg,rgba(50,35,28,.96),rgba(31,25,22,.98));border-color:rgba(207,109,60,.24);box-shadow:0 18px 34px rgba(0,0,0,.34)} - body[data-theme="dark"] .logEvent{background:linear-gradient(180deg,rgba(18,31,45,.96),rgba(14,24,36,.98));border-color:rgba(73,144,204,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)} - body[data-theme="dark"] .secretCard{background:linear-gradient(180deg,rgba(19,39,31,.96),rgba(14,30,24,.98));border-color:rgba(64,170,126,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)} - body[data-theme="dark"] .s3Card{background:linear-gradient(180deg,rgba(52,42,17,.96),rgba(37,30,13,.98));border-color:rgba(181,137,37,.24);box-shadow:0 16px 32px rgba(0,0,0,.34)} - body[data-theme="dark"] .healthBadge{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.18);box-shadow:0 10px 22px rgba(0,0,0,.28)} - body[data-theme="dark"] .healthBadge.active{border-color:rgba(207,109,60,.32);background:rgba(207,109,60,.18);box-shadow:0 12px 26px rgba(0,0,0,.3)} - body[data-theme="dark"] .healthBadge.active .healthBadgeName, - body[data-theme="dark"] .healthBadge.active .healthBadgeSummary{color:#f8ede6} - body[data-theme="dark"] .tab{color:#aab8c8} - body[data-theme="dark"] .tab.active, - body[data-theme="dark"] .ghost, - body[data-theme="dark"] .mini, - body[data-theme="dark"] .chip, - body[data-theme="dark"] .status, - body[data-theme="dark"] .search{background:rgba(18,25,35,.88);border-color:rgba(148,163,184,.18);color:#edf2f7} - body[data-theme="dark"] .chip select, - body[data-theme="dark"] .search::placeholder{color:#9fb0c2} - body[data-theme="dark"] .ghost, - body[data-theme="dark"] .mini, - body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)} - body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))} - body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)} - body[data-theme="dark"] .logSummary{background:linear-gradient(180deg,rgba(21,34,47,.94),rgba(16,27,39,.98))} - body[data-theme="dark"] .logBody{background:#13212d;border-top-color:rgba(73,144,204,.18)} - body[data-theme="dark"] .secretSummary{background:linear-gradient(180deg,rgba(21,43,34,.94),rgba(16,34,27,.98))} - body[data-theme="dark"] .secretBody{background:#12241c;border-top-color:rgba(64,170,126,.18)} - body[data-theme="dark"] .s3Summary{background:linear-gradient(180deg,rgba(53,41,19,.94),rgba(39,31,15,.98))} - body[data-theme="dark"] .s3Body{background:#241d10;border-top-color:rgba(181,137,37,.18)} - body[data-theme="dark"] .metaCard{background:rgba(17,25,35,.64);border-color:rgba(148,163,184,.14)} - body[data-theme="dark"] .attachment{background:rgba(50,35,28,.9);border-color:rgba(207,109,60,.18)} - body[data-theme="dark"] .attachmentLink{color:#f6c4a9} - body[data-theme="dark"] .attachmentLink:hover{background:rgba(75,52,39,.96);border-color:rgba(246,196,169,.26)} - body[data-theme="dark"] .panel, - body[data-theme="dark"] pre, - body[data-theme="dark"] .logBody pre{background:linear-gradient(180deg,rgba(73,144,204,.06),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} - body[data-theme="dark"] .secretValuePanel pre{background:linear-gradient(180deg,rgba(64,170,126,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} - body[data-theme="dark"] .s3PreviewPanel pre{background:linear-gradient(180deg,rgba(181,137,37,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} - body[data-theme="dark"] .panel{border-color:rgba(148,163,184,.14)} - body[data-theme="dark"] .banner, - body[data-theme="dark"] .empty{background:rgba(15,21,30,.82);border-color:rgba(148,163,184,.16)} - body[data-theme="dark"] .time{background:rgba(148,163,184,.12);color:#e8edf3} - body[data-theme="dark"] .tag{background:rgba(207,109,60,.14);color:#f0c2aa} - body[data-theme="dark"] .logTag{background:rgba(73,144,204,.16);color:#93cfff} - body[data-theme="dark"] .secretTag{background:rgba(64,170,126,.16);color:#9fe0be} - body[data-theme="dark"] .bucketTag{background:rgba(181,137,37,.16);color:#f1d38c} - body[data-theme="dark"] .preview, - body[data-theme="dark"] .logPreview, - body[data-theme="dark"] .metaCard dd, - body[data-theme="dark"] .head h2, - body[data-theme="dark"] .stat strong, - body[data-theme="dark"] h1{color:#edf2f7} - body[data-theme="dark"] .jsonSyntax .jsonKey{color:#f0b08a} - body[data-theme="dark"] .jsonSyntax .jsonString{color:#80d5b0} - body[data-theme="dark"] .jsonSyntax .jsonNumber{color:#94c9ff} - body[data-theme="dark"] .jsonSyntax .jsonBoolean{color:#f0c274} - body[data-theme="dark"] .jsonSyntax .jsonNull{color:#ff9c9c} - body[data-theme="dark"] .meta, - body[data-theme="dark"] .helper, - body[data-theme="dark"] .lede, - body[data-theme="dark"] .stat small, - body[data-theme="dark"] .stat span, - body[data-theme="dark"] .chip, - body[data-theme="dark"] .tab{color:#aab8c8} - body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7} - body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)} - @media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}} - @media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row,.heroActions{align-items:stretch}.heroTopRow{justify-content:stretch;flex-basis:100%}.heroStatusRow{align-items:flex-start}.heroStatusLabel,.healthStrip{flex-basis:100%}.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.healthBadge{justify-content:flex-start}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}} - `; -} - -function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function clientApp(config) { - const THEME_STORAGE_KEY = "localstack-inspector-theme"; - const PANEL_STORAGE_KEY = "localstack-inspector-panel"; - const EMAIL_PREFERENCES_STORAGE_KEY = "localstack-inspector-email-preferences"; - const LOG_PREFERENCES_STORAGE_KEY = "localstack-inspector-log-preferences"; - const SECRET_PREFERENCES_STORAGE_KEY = "localstack-inspector-secret-preferences"; - const S3_PREFERENCES_STORAGE_KEY = "localstack-inspector-s3-preferences"; - const HEALTH_REFRESH_MS = 30000; - const REFRESH_INTERVALS = [5000, 10000, 15000, 30000, 60000]; - const LOG_WINDOWS = [300000, 900000, 3600000, 21600000, 86400000]; - const LOG_LIMITS = [100, 200, 300, 500]; - const storedEmailPreferences = getStoredPreferences(EMAIL_PREFERENCES_STORAGE_KEY); - const storedLogPreferences = getStoredPreferences(LOG_PREFERENCES_STORAGE_KEY); - const storedSecretPreferences = getStoredPreferences(SECRET_PREFERENCES_STORAGE_KEY); - const storedS3Preferences = getStoredPreferences(S3_PREFERENCES_STORAGE_KEY); - - const appState = { - panel: getInitialPanel(), - logsReady: false, - secretsReady: false, - s3Ready: false, - theme: getInitialTheme() - }; - - const state = { - messages: [], - filtered: [], - search: getStoredText(storedEmailPreferences?.search), - auto: getStoredBoolean(storedEmailPreferences?.auto, true), - interval: getStoredNumber(storedEmailPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), - loading: false, - error: "", - updatedAt: 0, - source: "initial", - duration: 0, - parseErrors: 0, - newest: "", - newIds: new Set(), - knownIds: new Set(), - openIds: new Set(), - views: {}, - raw: {}, - listSignature: "" - }; - - const logsState = { - groups: [], - streams: [], - events: [], - filtered: [], - group: getStoredText(storedLogPreferences?.group, config.defaultLogGroup || ""), - stream: getStoredText(storedLogPreferences?.stream), - search: getStoredText(storedLogPreferences?.search), - auto: getStoredBoolean(storedLogPreferences?.auto, true), - interval: getStoredNumber(storedLogPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), - windowMs: getStoredNumber(storedLogPreferences?.windowMs, LOG_WINDOWS, config.defaultLogWindowMs), - limit: getStoredNumber(storedLogPreferences?.limit, LOG_LIMITS, config.defaultLogLimit), - wrapLines: getStoredBoolean(storedLogPreferences?.wrapLines, true), - tailNewest: getStoredBoolean(storedLogPreferences?.tailNewest, false), - loading: false, - error: "", - updatedAt: 0, - source: "initial", - duration: 0, - newest: 0, - searchedLogStreams: 0, - openIds: new Set(), - listSignature: "" - }; - - const secretsState = { - items: [], - filtered: [], - search: getStoredText(storedSecretPreferences?.search), - auto: getStoredBoolean(storedSecretPreferences?.auto, true), - interval: getStoredNumber(storedSecretPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), - loading: false, - error: "", - updatedAt: 0, - source: "initial", - duration: 0, - newest: "", - openIds: new Set(), - values: {}, - revealedIds: new Set(), - listSignature: "" - }; - - const s3State = { - buckets: [], - objects: [], - filtered: [], - bucket: getStoredText(storedS3Preferences?.bucket, config.defaultS3Bucket || ""), - prefix: getStoredText(storedS3Preferences?.prefix), - search: getStoredText(storedS3Preferences?.search), - auto: getStoredBoolean(storedS3Preferences?.auto, true), - interval: getStoredNumber(storedS3Preferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), - loading: false, - error: "", - updatedAt: 0, - source: "initial", - duration: 0, - newest: "", - openIds: new Set(), - previews: {}, - listSignature: "" - }; - - const healthState = { - services: {}, - loading: false, - error: "", - updatedAt: 0, - source: "initial" - }; - - const el = { - themeToggle: document.getElementById("themeToggle"), - resetStateButton: document.getElementById("resetStateButton"), - healthRefreshButton: document.getElementById("healthRefreshButton"), - healthStrip: document.getElementById("healthStrip"), - emailsPanel: document.getElementById("emailsPanel"), - logsPanel: document.getElementById("logsPanel"), - secretsPanel: document.getElementById("secretsPanel"), - s3Panel: document.getElementById("s3Panel"), - refreshButton: document.getElementById("refreshButton"), - autoToggle: document.getElementById("autoToggle"), - intervalSelect: document.getElementById("intervalSelect"), - searchInput: document.getElementById("searchInput"), - clearSearchButton: document.getElementById("clearSearchButton"), - expandAllButton: document.getElementById("expandAllButton"), - collapseAllButton: document.getElementById("collapseAllButton"), - scrollToTopButton: document.getElementById("scrollToTopButton"), - statusChip: document.getElementById("statusChip"), - totalStat: document.getElementById("totalStat"), - visibleStat: document.getElementById("visibleStat"), - newStat: document.getElementById("newStat"), - newestStat: document.getElementById("newestStat"), - updatedStat: document.getElementById("updatedStat"), - fetchStat: document.getElementById("fetchStat"), - fetchDetail: document.getElementById("fetchDetail"), - banner: document.getElementById("banner"), - empty: document.getElementById("empty"), - list: document.getElementById("list"), - emailsContentPane: document.getElementById("emailsContentPane"), - logsRefreshButton: document.getElementById("logsRefreshButton"), - logsAutoToggle: document.getElementById("logsAutoToggle"), - logsIntervalSelect: document.getElementById("logsIntervalSelect"), - logsGroupSelect: document.getElementById("logsGroupSelect"), - logsStreamSelect: document.getElementById("logsStreamSelect"), - logsWindowSelect: document.getElementById("logsWindowSelect"), - logsLimitSelect: document.getElementById("logsLimitSelect"), - logsSearchInput: document.getElementById("logsSearchInput"), - logsClearSearchButton: document.getElementById("logsClearSearchButton"), - logsWrapToggle: document.getElementById("logsWrapToggle"), - logsTailToggle: document.getElementById("logsTailToggle"), - logsExpandAllButton: document.getElementById("logsExpandAllButton"), - logsCollapseAllButton: document.getElementById("logsCollapseAllButton"), - logsScrollToTopButton: document.getElementById("logsScrollToTopButton"), - logsStatusChip: document.getElementById("logsStatusChip"), - logsTotalStat: document.getElementById("logsTotalStat"), - logsVisibleStat: document.getElementById("logsVisibleStat"), - logsStreamsStat: document.getElementById("logsStreamsStat"), - logsNewestStat: document.getElementById("logsNewestStat"), - logsUpdatedStat: document.getElementById("logsUpdatedStat"), - logsFetchStat: document.getElementById("logsFetchStat"), - logsFetchDetail: document.getElementById("logsFetchDetail"), - logsBanner: document.getElementById("logsBanner"), - logsEmpty: document.getElementById("logsEmpty"), - logsList: document.getElementById("logsList"), - logsContentPane: document.getElementById("logsContentPane"), - secretsRefreshButton: document.getElementById("secretsRefreshButton"), - secretsAutoToggle: document.getElementById("secretsAutoToggle"), - secretsIntervalSelect: document.getElementById("secretsIntervalSelect"), - secretsSearchInput: document.getElementById("secretsSearchInput"), - secretsClearSearchButton: document.getElementById("secretsClearSearchButton"), - secretsExpandAllButton: document.getElementById("secretsExpandAllButton"), - secretsCollapseAllButton: document.getElementById("secretsCollapseAllButton"), - secretsScrollToTopButton: document.getElementById("secretsScrollToTopButton"), - secretsStatusChip: document.getElementById("secretsStatusChip"), - secretsTotalStat: document.getElementById("secretsTotalStat"), - secretsVisibleStat: document.getElementById("secretsVisibleStat"), - secretsLoadedStat: document.getElementById("secretsLoadedStat"), - secretsNewestStat: document.getElementById("secretsNewestStat"), - secretsUpdatedStat: document.getElementById("secretsUpdatedStat"), - secretsFetchStat: document.getElementById("secretsFetchStat"), - secretsFetchDetail: document.getElementById("secretsFetchDetail"), - secretsBanner: document.getElementById("secretsBanner"), - secretsEmpty: document.getElementById("secretsEmpty"), - secretsList: document.getElementById("secretsList"), - secretsContentPane: document.getElementById("secretsContentPane"), - s3RefreshButton: document.getElementById("s3RefreshButton"), - s3AutoToggle: document.getElementById("s3AutoToggle"), - s3IntervalSelect: document.getElementById("s3IntervalSelect"), - s3BucketSelect: document.getElementById("s3BucketSelect"), - s3PrefixInput: document.getElementById("s3PrefixInput"), - s3ApplyPrefixButton: document.getElementById("s3ApplyPrefixButton"), - s3SearchInput: document.getElementById("s3SearchInput"), - s3ClearSearchButton: document.getElementById("s3ClearSearchButton"), - s3ExpandAllButton: document.getElementById("s3ExpandAllButton"), - s3CollapseAllButton: document.getElementById("s3CollapseAllButton"), - s3ScrollToTopButton: document.getElementById("s3ScrollToTopButton"), - s3StatusChip: document.getElementById("s3StatusChip"), - s3TotalStat: document.getElementById("s3TotalStat"), - s3VisibleStat: document.getElementById("s3VisibleStat"), - s3BucketsStat: document.getElementById("s3BucketsStat"), - s3NewestStat: document.getElementById("s3NewestStat"), - s3UpdatedStat: document.getElementById("s3UpdatedStat"), - s3FetchStat: document.getElementById("s3FetchStat"), - s3FetchDetail: document.getElementById("s3FetchDetail"), - s3Banner: document.getElementById("s3Banner"), - s3Empty: document.getElementById("s3Empty"), - s3List: document.getElementById("s3List"), - s3ContentPane: document.getElementById("s3ContentPane") - }; - - el.autoToggle.checked = state.auto; - el.intervalSelect.value = String(state.interval); - el.searchInput.value = state.search; - el.logsAutoToggle.checked = logsState.auto; - el.logsIntervalSelect.value = String(logsState.interval); - el.logsWindowSelect.value = String(logsState.windowMs); - el.logsLimitSelect.value = String(logsState.limit); - el.logsSearchInput.value = logsState.search; - el.logsWrapToggle.checked = logsState.wrapLines; - el.logsTailToggle.checked = logsState.tailNewest; - el.secretsAutoToggle.checked = secretsState.auto; - el.secretsIntervalSelect.value = String(secretsState.interval); - el.secretsSearchInput.value = secretsState.search; - el.s3AutoToggle.checked = s3State.auto; - el.s3IntervalSelect.value = String(s3State.interval); - el.s3PrefixInput.value = s3State.prefix; - el.s3SearchInput.value = s3State.search; - applyTheme(appState.theme); - persistPanel(); - persistEmailPreferences(); - persistLogPreferences(); - persistSecretPreferences(); - persistS3Preferences(); - wire(); - renderWorkspace(); - renderAll(); - renderLogsAll(); - renderSecretsAll(); - renderS3All(); - renderHealthStrip(); - if (appState.panel === "logs") { - ensureLogsReady(); - } else if (appState.panel === "secrets") { - ensureSecretsReady(); - } else if (appState.panel === "s3") { - ensureS3Ready(); - } else { - refreshMessages("initial"); - } - refreshHealthSummary("initial"); - window.setInterval(() => { - if (!document.hidden) { - refreshHealthSummary("auto"); - } - }, HEALTH_REFRESH_MS); - window.setInterval(() => { - renderLiveClock(); - renderLogsLiveClock(); - renderSecretsLiveClock(); - renderS3LiveClock(); - renderHealthStrip(); - }, 1000); - - function wire() { - el.themeToggle.addEventListener("click", () => { - applyTheme(appState.theme === "dark" ? "light" : "dark"); - }); - - el.resetStateButton.addEventListener("click", () => { - resetSavedState(); - }); - - el.healthRefreshButton.addEventListener("click", () => { - refreshHealthSummary("manual"); - }); - - el.healthStrip.addEventListener("click", async (event) => { - const button = event.target.closest("button[data-health-panel]"); - - if (!button) { - return; - } - - await setPanel(button.dataset.healthPanel); - }); - - el.refreshButton.addEventListener("click", () => refreshMessages("manual")); - - el.autoToggle.addEventListener("change", () => { - state.auto = el.autoToggle.checked; - persistEmailPreferences(); - scheduleRefresh(); - renderStatus(); - }); - - el.intervalSelect.addEventListener("change", () => { - state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs; - persistEmailPreferences(); - scheduleRefresh(); - renderStatus(); - }); - - el.searchInput.addEventListener("input", (event) => { - state.search = event.target.value; - applyFilter(); - }); - - el.clearSearchButton.addEventListener("click", () => { - state.search = ""; - el.searchInput.value = ""; - applyFilter(); - }); - - el.expandAllButton.addEventListener("click", () => { - state.filtered.forEach((message) => state.openIds.add(message.id)); - syncCardExpansion(); - }); - - el.collapseAllButton.addEventListener("click", () => { - state.openIds.clear(); - syncCardExpansion(); - }); - - el.scrollToTopButton.addEventListener("click", () => { - scrollPaneToTop(el.emailsContentPane); - }); - - el.emailsContentPane.addEventListener("scroll", () => { - updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); - }); - - el.list.addEventListener("click", async (event) => { - const button = event.target.closest("button[data-action]"); - - if (!button) { - return; - } - - const id = button.dataset.id; - const message = getMessage(id); - - if (!message) { - return; - } - - if (button.dataset.action === "view") { - state.views[id] = button.dataset.view; - renderList(); - return; - } - - if (button.dataset.action === "load-raw") { - await loadRaw(id); - renderList(); - return; - } - - if (button.dataset.action === "copy-raw") { - const raw = await loadRaw(id); - - if (raw) { - await copyText(raw); - setStatus("Raw message copied to the clipboard.", "ok"); - } - } - }); - - el.logsRefreshButton.addEventListener("click", () => refreshLogs("manual")); - - el.logsAutoToggle.addEventListener("change", () => { - logsState.auto = el.logsAutoToggle.checked; - persistLogPreferences(); - scheduleLogsRefresh(); - renderLogsStatus(); - }); - - el.logsIntervalSelect.addEventListener("change", () => { - logsState.interval = Number(el.logsIntervalSelect.value) || config.defaultRefreshMs; - persistLogPreferences(); - scheduleLogsRefresh(); - renderLogsStatus(); - }); - - el.logsWindowSelect.addEventListener("change", () => { - logsState.windowMs = Number(el.logsWindowSelect.value) || config.defaultLogWindowMs; - persistLogPreferences(); - refreshLogs("window"); - }); - - el.logsLimitSelect.addEventListener("change", () => { - logsState.limit = Number(el.logsLimitSelect.value) || config.defaultLogLimit; - persistLogPreferences(); - refreshLogs("limit"); - }); - - el.logsSearchInput.addEventListener("input", (event) => { - logsState.search = event.target.value; - applyLogsFilter(); - }); - - el.logsClearSearchButton.addEventListener("click", () => { - logsState.search = ""; - el.logsSearchInput.value = ""; - applyLogsFilter(); - }); - - el.logsWrapToggle.addEventListener("change", () => { - logsState.wrapLines = el.logsWrapToggle.checked; - persistLogPreferences(); - renderLogsList(); - }); - - el.logsTailToggle.addEventListener("change", () => { - logsState.tailNewest = el.logsTailToggle.checked; - persistLogPreferences(); - }); - - el.logsExpandAllButton.addEventListener("click", () => { - logsState.filtered.forEach((event) => logsState.openIds.add(event.id)); - renderLogsList(); - }); - - el.logsCollapseAllButton.addEventListener("click", () => { - logsState.openIds.clear(); - renderLogsList(); - }); - - el.logsScrollToTopButton.addEventListener("click", () => { - scrollPaneToTop(el.logsContentPane); - }); - - el.logsContentPane.addEventListener("scroll", () => { - updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); - }); - - el.logsGroupSelect.addEventListener("change", async () => { - logsState.group = el.logsGroupSelect.value; - logsState.stream = ""; - persistLogPreferences(); - await refreshLogStreams(); - await refreshLogs("group"); - }); - - el.logsStreamSelect.addEventListener("change", () => { - logsState.stream = el.logsStreamSelect.value; - persistLogPreferences(); - refreshLogs("stream"); - }); - - el.logsList.addEventListener("click", async (event) => { - const button = event.target.closest("button[data-log-action]"); - - if (!button) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const logEvent = getLogEvent(button.dataset.id); - - if (!logEvent) { - return; - } - - if (button.dataset.logAction === "copy") { - await copyText(formatLogMessage(logEvent.message)); - setLogsStatus("Log payload copied to the clipboard.", "ok"); - } - }); - - el.secretsRefreshButton.addEventListener("click", () => refreshSecrets("manual")); - - el.secretsAutoToggle.addEventListener("change", () => { - secretsState.auto = el.secretsAutoToggle.checked; - persistSecretPreferences(); - scheduleSecretsRefresh(); - renderSecretsStatus(); - }); - - el.secretsIntervalSelect.addEventListener("change", () => { - secretsState.interval = Number(el.secretsIntervalSelect.value) || config.defaultRefreshMs; - persistSecretPreferences(); - scheduleSecretsRefresh(); - renderSecretsStatus(); - }); - - el.secretsSearchInput.addEventListener("input", (event) => { - secretsState.search = event.target.value; - applySecretsFilter(); - }); - - el.secretsClearSearchButton.addEventListener("click", () => { - secretsState.search = ""; - el.secretsSearchInput.value = ""; - applySecretsFilter(); - }); - - el.secretsExpandAllButton.addEventListener("click", () => { - secretsState.filtered.forEach((secret) => secretsState.openIds.add(secret.id)); - syncSecretExpansion(); - }); - - el.secretsCollapseAllButton.addEventListener("click", () => { - secretsState.openIds.clear(); - syncSecretExpansion(); - }); - - el.secretsScrollToTopButton.addEventListener("click", () => { - scrollPaneToTop(el.secretsContentPane); - }); - - el.secretsContentPane.addEventListener("scroll", () => { - updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); - }); - - el.secretsList.addEventListener("click", async (event) => { - const button = event.target.closest("button[data-secret-action]"); - - if (!button) { - return; - } - - const id = button.dataset.id; - const secret = getSecret(id); - - if (!secret) { - return; - } - - if (button.dataset.secretAction === "load-value") { - await ensureSecretValue(id, { force: false }); - return; - } - - if (button.dataset.secretAction === "reload-value") { - await ensureSecretValue(id, { force: true }); - return; - } - - if (button.dataset.secretAction === "copy-value") { - const entry = await ensureSecretValue(id, { force: false }); - - if (entry?.status === "loaded") { - await copyText(entry.copyValue); - setSecretsStatus("Secret value copied to the clipboard.", "ok"); - } - - return; - } - - if (button.dataset.secretAction === "toggle-reveal") { - toggleSecretReveal(id); - return; - } - - if (button.dataset.secretAction === "copy-name") { - await copyText(secret.name || ""); - setSecretsStatus("Secret name copied to the clipboard.", "ok"); - return; - } - - if (button.dataset.secretAction === "copy-arn") { - await copyText(secret.arn || ""); - setSecretsStatus("Secret ARN copied to the clipboard.", "ok"); - return; - } - - if (button.dataset.secretAction === "copy-env") { - const entry = await ensureSecretValue(id, { force: false }); - - if (entry?.status === "loaded") { - await copyText(`${secret.name}=${entry.copyValue}`); - setSecretsStatus("Secret env line copied to the clipboard.", "ok"); - } - } - }); - - el.s3RefreshButton.addEventListener("click", async () => { - await refreshS3Buckets(); - await refreshS3("manual"); - }); - - el.s3AutoToggle.addEventListener("change", () => { - s3State.auto = el.s3AutoToggle.checked; - persistS3Preferences(); - scheduleS3Refresh(); - renderS3Status(); - }); - - el.s3IntervalSelect.addEventListener("change", () => { - s3State.interval = Number(el.s3IntervalSelect.value) || config.defaultRefreshMs; - persistS3Preferences(); - scheduleS3Refresh(); - renderS3Status(); - }); - - el.s3BucketSelect.addEventListener("change", () => { - s3State.bucket = el.s3BucketSelect.value; - persistS3Preferences(); - refreshS3("bucket"); - }); - - el.s3PrefixInput.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - event.preventDefault(); - s3State.prefix = el.s3PrefixInput.value.trim(); - persistS3Preferences(); - refreshS3("prefix"); - } - }); - - el.s3ApplyPrefixButton.addEventListener("click", () => { - s3State.prefix = el.s3PrefixInput.value.trim(); - persistS3Preferences(); - refreshS3("prefix"); - }); - - el.s3SearchInput.addEventListener("input", (event) => { - s3State.search = event.target.value; - applyS3Filter(); - }); - - el.s3ClearSearchButton.addEventListener("click", () => { - s3State.search = ""; - el.s3SearchInput.value = ""; - applyS3Filter(); - }); - - el.s3ExpandAllButton.addEventListener("click", () => { - s3State.filtered.forEach((object) => s3State.openIds.add(object.id)); - syncS3Expansion(); - }); - - el.s3CollapseAllButton.addEventListener("click", () => { - s3State.openIds.clear(); - syncS3Expansion(); - }); - - el.s3ScrollToTopButton.addEventListener("click", () => { - scrollPaneToTop(el.s3ContentPane); - }); - - el.s3ContentPane.addEventListener("scroll", () => { - updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); - }); - - el.s3List.addEventListener("click", async (event) => { - const button = event.target.closest("button[data-s3-action]"); - - if (!button) { - return; - } - - const id = button.dataset.id; - const object = getS3Object(id); - - if (!object) { - return; - } - - if (button.dataset.s3Action === "load-preview") { - await ensureS3Preview(id, { force: false }); - return; - } - - if (button.dataset.s3Action === "reload-preview") { - await ensureS3Preview(id, { force: true }); - return; - } - - if (button.dataset.s3Action === "copy-key") { - await copyText(object.key); - setS3Status("Object key copied to the clipboard.", "ok"); - return; - } - - if (button.dataset.s3Action === "copy-uri") { - await copyText(`s3://${object.bucket}/${object.key}`); - setS3Status("S3 URI copied to the clipboard.", "ok"); - } - }); - - document.addEventListener("visibilitychange", () => { - window.clearTimeout(state.timer); - window.clearTimeout(logsState.timer); - window.clearTimeout(secretsState.timer); - window.clearTimeout(s3State.timer); - - if (document.hidden) { - renderStatus(); - renderLogsStatus(); - renderSecretsStatus(); - renderS3Status(); - return; - } - - refreshHealthSummary("visibility"); - - if (appState.panel === "s3") { - if (s3State.auto) { - refreshS3("visibility"); - } else { - renderS3Status(); - } - return; - } - - if (appState.panel === "secrets") { - if (secretsState.auto) { - refreshSecrets("visibility"); - } else { - renderSecretsStatus(); - } - return; - } - - if (appState.panel === "logs") { - if (logsState.auto) { - refreshLogs("visibility"); - } else { - renderLogsStatus(); - } - return; - } - - if (state.auto) { - refreshMessages("visibility"); - } else { - renderStatus(); - } - }); - - window.addEventListener("keydown", (event) => { - const isField = - event.target instanceof HTMLElement && - (event.target.matches("input,textarea,select") || event.target.isContentEditable); - - if (!isField && event.key.toLowerCase() === "r") { - event.preventDefault(); - - if (appState.panel === "logs") { - refreshLogs("keyboard"); - return; - } - - if (appState.panel === "secrets") { - refreshSecrets("keyboard"); - return; - } - - if (appState.panel === "s3") { - refreshS3("keyboard"); - return; - } - - refreshMessages("keyboard"); - } - }); - - updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); - updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); - updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); - updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); - } - - async function setPanel(panel) { - if (!panel || panel === appState.panel) { - if (panel === "logs") { - await ensureLogsReady(); - } else if (panel === "secrets") { - await ensureSecretsReady(); - } else if (panel === "s3") { - await ensureS3Ready(); - } - - renderWorkspace(); - return; - } - - appState.panel = panel; - persistPanel(); - renderWorkspace(); - - if (panel === "logs") { - await ensureLogsReady(); - } else if (panel === "secrets") { - await ensureSecretsReady(); - } else if (panel === "s3") { - await ensureS3Ready(); - } else if (!state.updatedAt && !state.loading) { - await refreshMessages("panel"); - } - - scheduleRefresh(); - scheduleLogsRefresh(); - scheduleSecretsRefresh(); - scheduleS3Refresh(); - renderStatus(); - renderLogsStatus(); - renderSecretsStatus(); - renderS3Status(); - } - - function renderWorkspace() { - el.emailsPanel.hidden = appState.panel !== "emails"; - el.logsPanel.hidden = appState.panel !== "logs"; - el.secretsPanel.hidden = appState.panel !== "secrets"; - el.s3Panel.hidden = appState.panel !== "s3"; - renderHealthStrip(); - } - - async function ensureLogsReady() { - if (appState.logsReady) { - return; - } - - await refreshLogGroups(); - appState.logsReady = !logsState.error; - - if (logsState.group) { - await refreshLogs("initial"); - } - } - - async function ensureSecretsReady() { - if (appState.secretsReady) { - return; - } - - await refreshSecrets("initial"); - appState.secretsReady = !secretsState.error; - } - - async function ensureS3Ready() { - if (appState.s3Ready) { - return; - } - - await refreshS3Buckets(); - appState.s3Ready = !s3State.error; - - if (s3State.bucket) { - await refreshS3("initial"); - } - } - - async function refreshMessages(source) { - if (state.loading) { - return; - } - - let shouldRenderList = false; - - state.loading = true; - state.source = source; - state.error = ""; - renderStatus(); - renderFetch(); - - try { - const response = await fetch("/api/messages", { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - const messages = Array.isArray(payload.messages) ? payload.messages : []; - const nextIds = new Set(messages.map((message) => message.id)); - const nextSignature = computeListSignature(messages); - - shouldRenderList = nextSignature !== state.listSignature; - state.newIds = - state.updatedAt && shouldRenderList - ? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id)) - : state.newIds; - state.knownIds = nextIds; - state.messages = messages; - state.duration = payload.fetchDurationMs || 0; - state.parseErrors = payload.parseErrors || 0; - state.newest = payload.latestMessageTimestamp || ""; - state.updatedAt = Date.now(); - state.listSignature = nextSignature; - - pruneState(); - applyFilter(shouldRenderList); - setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok"); - } catch (error) { - state.error = error.message || "Unknown refresh error"; - setStatus(`Refresh failed: ${state.error}`, "bad"); - } finally { - state.loading = false; - scheduleRefresh(); - renderAll({ renderList: shouldRenderList }); - } - } - - async function refreshLogGroups() { - try { - const response = await fetch("/api/logs/groups", { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - logsState.groups = Array.isArray(payload.groups) ? payload.groups : []; - - const availableGroups = logsState.groups.map((group) => group.name).filter(Boolean); - - if (!availableGroups.includes(logsState.group)) { - logsState.group = availableGroups.includes(config.defaultLogGroup) - ? config.defaultLogGroup - : availableGroups[0] || ""; - } - - logsState.error = ""; - await refreshLogStreams(); - } catch (error) { - logsState.error = error.message || "Unknown log group refresh error"; - } finally { - persistLogPreferences(); - renderLogsAll(); - } - } - - async function refreshLogStreams() { - if (!logsState.group) { - logsState.streams = []; - logsState.stream = ""; - renderLogsAll(); - return; - } - - try { - const response = await fetch(`/api/logs/streams?group=${encodeURIComponent(logsState.group)}`, { - cache: "no-store" - }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - logsState.streams = Array.isArray(payload.streams) ? payload.streams : []; - - if (!logsState.streams.some((stream) => stream.name === logsState.stream)) { - logsState.stream = ""; - } - - logsState.error = ""; - } catch (error) { - logsState.streams = []; - logsState.stream = ""; - logsState.error = error.message || "Unknown log stream refresh error"; - } finally { - persistLogPreferences(); - renderLogsAll(); - } - } - - async function refreshLogs(source) { - if (logsState.loading) { - return; - } - - if (!appState.logsReady) { - await ensureLogsReady(); - return; - } - - if (!logsState.group) { - logsState.error = logsState.groups.length ? "Choose a log group to load events." : "No log groups found."; - renderLogsAll(); - return; - } - - let shouldRenderList = false; - - logsState.loading = true; - logsState.source = source; - logsState.error = ""; - renderLogsStatus(); - renderLogsFetch(); - - try { - const params = new URLSearchParams({ - group: logsState.group, - windowMs: String(logsState.windowMs), - limit: String(logsState.limit) - }); - - if (logsState.stream) { - params.set("stream", logsState.stream); - } - - const response = await fetch(`/api/logs/events?${params.toString()}`, { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - const events = Array.isArray(payload.events) ? payload.events : []; - const nextSignature = computeLogListSignature(events); - shouldRenderList = nextSignature !== logsState.listSignature; - logsState.events = events; - logsState.duration = payload.fetchDurationMs || 0; - logsState.newest = payload.latestTimestamp || 0; - logsState.updatedAt = Date.now(); - logsState.searchedLogStreams = payload.searchedLogStreams || (logsState.stream ? 1 : logsState.streams.length); - logsState.listSignature = nextSignature; - - pruneLogsState(); - applyLogsFilter(shouldRenderList); - setLogsStatus(`Updated ${logsState.events.length} log event${logsState.events.length === 1 ? "" : "s"}.`, "ok"); - } catch (error) { - logsState.error = error.message || "Unknown log refresh error"; - setLogsStatus(`Refresh failed: ${logsState.error}`, "bad"); - } finally { - logsState.loading = false; - scheduleLogsRefresh(); - renderLogsAll({ renderList: shouldRenderList }); - - if (shouldRenderList && logsState.tailNewest) { - scrollPaneToTop(el.logsContentPane); - } - } - } - - async function refreshSecrets(source) { - if (secretsState.loading) { - return; - } - - let shouldRenderList = false; - - secretsState.loading = true; - secretsState.source = source; - secretsState.error = ""; - renderSecretsStatus(); - renderSecretsFetch(); - - try { - const response = await fetch("/api/secrets", { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - const items = Array.isArray(payload.secrets) ? payload.secrets : []; - const nextSignature = computeSecretsListSignature(items); - shouldRenderList = nextSignature !== secretsState.listSignature; - secretsState.items = items; - secretsState.duration = payload.fetchDurationMs || 0; - secretsState.newest = payload.latestTimestamp || ""; - secretsState.updatedAt = Date.now(); - secretsState.listSignature = nextSignature; - - pruneSecretsState(); - applySecretsFilter(shouldRenderList); - appState.secretsReady = true; - setSecretsStatus(`Updated ${items.length} secret${items.length === 1 ? "" : "s"}.`, "ok"); - } catch (error) { - secretsState.error = error.message || "Unknown secrets refresh error"; - appState.secretsReady = false; - setSecretsStatus(`Refresh failed: ${secretsState.error}`, "bad"); - } finally { - secretsState.loading = false; - scheduleSecretsRefresh(); - renderSecretsAll({ renderList: shouldRenderList }); - } - } - - async function refreshHealthSummary(source) { - if (healthState.loading && source === "auto") { - return; - } - - healthState.loading = true; - healthState.source = source; - renderHealthStrip(); - - try { - const response = await fetch("/api/service-health", { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - healthState.services = payload.services || {}; - healthState.updatedAt = Date.now(); - healthState.error = ""; - } catch (error) { - healthState.error = error.message || "Unknown health refresh error"; - } finally { - healthState.loading = false; - renderHealthStrip(); - } - } - - async function refreshS3Buckets() { - try { - const response = await fetch("/api/s3/buckets", { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - s3State.buckets = Array.isArray(payload.buckets) ? payload.buckets : []; - - const availableBuckets = s3State.buckets.map((bucket) => bucket.name).filter(Boolean); - - if (!availableBuckets.includes(s3State.bucket)) { - s3State.bucket = availableBuckets.includes(config.defaultS3Bucket) - ? config.defaultS3Bucket - : availableBuckets[0] || ""; - } - - s3State.error = ""; - } catch (error) { - s3State.buckets = []; - s3State.bucket = ""; - s3State.error = error.message || "Unknown S3 bucket refresh error"; - } finally { - appState.s3Ready = !s3State.error; - persistS3Preferences(); - renderS3All(); - } - } - - async function refreshS3(source) { - if (s3State.loading) { - return; - } - - if (!appState.s3Ready) { - await ensureS3Ready(); - return; - } - - if (!s3State.bucket) { - s3State.error = s3State.buckets.length ? "Choose a bucket to load objects." : "No S3 buckets found."; - renderS3All(); - return; - } - - let shouldRenderList = false; - - s3State.loading = true; - s3State.source = source; - s3State.error = ""; - renderS3Status(); - renderS3Fetch(); - - try { - const params = new URLSearchParams({ - bucket: s3State.bucket - }); - - if (s3State.prefix) { - params.set("prefix", s3State.prefix); - } - - const response = await fetch(`/api/s3/objects?${params.toString()}`, { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - const objects = Array.isArray(payload.objects) ? payload.objects : []; - const nextSignature = computeS3ListSignature(objects); - shouldRenderList = nextSignature !== s3State.listSignature; - s3State.objects = objects; - s3State.duration = payload.fetchDurationMs || 0; - s3State.newest = payload.latestTimestamp || ""; - s3State.updatedAt = Date.now(); - s3State.listSignature = nextSignature; - - pruneS3State(); - applyS3Filter(shouldRenderList); - appState.s3Ready = true; - setS3Status(`Updated ${objects.length} object${objects.length === 1 ? "" : "s"}.`, "ok"); - } catch (error) { - s3State.error = error.message || "Unknown S3 refresh error"; - appState.s3Ready = false; - setS3Status(`Refresh failed: ${s3State.error}`, "bad"); - } finally { - s3State.loading = false; - scheduleS3Refresh(); - renderS3All({ renderList: shouldRenderList }); - } - } - - function applyLogsFilter(shouldRenderList = true) { - const search = logsState.search.trim().toLowerCase(); - logsState.filtered = !search - ? [...logsState.events] - : logsState.events.filter((event) => logHaystack(event).includes(search)); - persistLogPreferences(); - renderLogsAll({ renderList: shouldRenderList }); - } - - function applySecretsFilter(shouldRenderList = true) { - const search = secretsState.search.trim().toLowerCase(); - secretsState.filtered = !search - ? [...secretsState.items] - : secretsState.items.filter((secret) => secretHaystack(secret).includes(search)); - persistSecretPreferences(); - renderSecretsAll({ renderList: shouldRenderList }); - } - - function applyS3Filter(shouldRenderList = true) { - const search = s3State.search.trim().toLowerCase(); - s3State.filtered = !search - ? [...s3State.objects] - : s3State.objects.filter((object) => s3Haystack(object).includes(search)); - persistS3Preferences(); - renderS3All({ renderList: shouldRenderList }); - } - - function pruneState() { - const ids = new Set(state.messages.map((message) => message.id)); - state.openIds = new Set([...state.openIds].filter((id) => ids.has(id))); - state.newIds = new Set([...state.newIds].filter((id) => ids.has(id))); - - Object.keys(state.views).forEach((id) => { - if (!ids.has(id)) { - delete state.views[id]; - } - }); - - Object.keys(state.raw).forEach((id) => { - if (!ids.has(id)) { - delete state.raw[id]; - } - }); - } - - function pruneLogsState() { - const ids = new Set(logsState.events.map((event) => event.id)); - logsState.openIds = new Set([...logsState.openIds].filter((id) => ids.has(id))); - } - - function pruneSecretsState() { - const ids = new Set(secretsState.items.map((secret) => secret.id)); - secretsState.openIds = new Set([...secretsState.openIds].filter((id) => ids.has(id))); - - Object.keys(secretsState.values).forEach((id) => { - if (!ids.has(id)) { - delete secretsState.values[id]; - } - }); - - secretsState.revealedIds = new Set([...secretsState.revealedIds].filter((id) => ids.has(id))); - } - - function pruneS3State() { - const ids = new Set(s3State.objects.map((object) => object.id)); - s3State.openIds = new Set([...s3State.openIds].filter((id) => ids.has(id))); - - Object.keys(s3State.previews).forEach((id) => { - if (!ids.has(id)) { - delete s3State.previews[id]; - } - }); - } - - function applyFilter(shouldRenderList = true) { - const search = state.search.trim().toLowerCase(); - state.filtered = !search - ? [...state.messages] - : state.messages.filter((message) => haystack(message).includes(search)); - persistEmailPreferences(); - renderAll({ renderList: shouldRenderList }); - } - - function computeListSignature(messages) { - return messages - .map((message) => - [ - message.id, - message.timestampMs || 0, - message.rawSizeBytes || 0, - message.attachmentCount || 0, - message.hasHtml ? 1 : 0, - message.preview || "", - message.parseError || "" - ].join("::") - ) - .join("|"); - } - - function computeLogListSignature(events) { - return events - .map((event) => - [event.id, event.timestamp || 0, event.ingestionTime || 0, event.logStreamName || "", event.message || ""].join( - "::" - ) - ) - .join("|"); - } - - function computeSecretsListSignature(items) { - return items - .map((secret) => - [ - secret.id, - secret.name || "", - secret.arn || "", - secret.description || "", - secret.lastChangedDate || "", - secret.createdDate || "", - secret.rotationEnabled ? 1 : 0, - secret.owningService || "", - secret.primaryRegion || "", - secret.versionCount || 0, - secret.tagCount || 0 - ].join("::") - ) - .join("|"); - } - - function computeS3ListSignature(objects) { - return objects - .map((object) => - [object.id, object.key || "", object.size || 0, object.lastModified || "", object.etag || ""].join("::") - ) - .join("|"); - } - - function haystack(message) { - return [ - message.subject, - message.from, - message.to, - message.replyTo, - message.preview, - message.textContent, - message.region, - ...(message.attachments || []).flatMap((attachment) => [attachment.filename, attachment.contentType]) - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - } - - function logHaystack(event) { - return [event.logStreamName, event.message, event.preview].filter(Boolean).join(" ").toLowerCase(); - } - - function secretHaystack(secret) { - return [ - secret.name, - secret.arn, - secret.description, - secret.primaryRegion, - secret.owningService, - ...(secret.tags || []).flatMap((tag) => [tag.key, tag.value]) - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - } - - function s3Haystack(object) { - return [object.bucket, object.key, object.storageClass, object.etag].filter(Boolean).join(" ").toLowerCase(); - } - - function scheduleRefresh() { - window.clearTimeout(state.timer); - - if (appState.panel !== "emails" || !state.auto || document.hidden || state.loading) { - return; - } - - state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval); - } - - function scheduleLogsRefresh() { - window.clearTimeout(logsState.timer); - - if (appState.panel !== "logs" || !logsState.auto || document.hidden || logsState.loading) { - return; - } - - logsState.timer = window.setTimeout(() => refreshLogs("auto"), logsState.interval); - } - - function scheduleSecretsRefresh() { - window.clearTimeout(secretsState.timer); - - if (appState.panel !== "secrets" || !secretsState.auto || document.hidden || secretsState.loading) { - return; - } - - secretsState.timer = window.setTimeout(() => refreshSecrets("auto"), secretsState.interval); - } - - function scheduleS3Refresh() { - window.clearTimeout(s3State.timer); - - if (appState.panel !== "s3" || !s3State.auto || document.hidden || s3State.loading) { - return; - } - - s3State.timer = window.setTimeout(() => refreshS3("auto"), s3State.interval); - } - - function renderAll(options = {}) { - const { renderList: shouldRenderList = true } = options; - renderStats(); - renderFetch(); - renderStatus(); - if (shouldRenderList) { - renderList(); - } - renderLiveClock(); - } - - function renderLogsAll(options = {}) { - const { renderList: shouldRenderList = true } = options; - renderLogsFilters(); - renderLogsStats(); - renderLogsFetch(); - renderLogsStatus(); - if (shouldRenderList) { - renderLogsList(); - } - renderLogsLiveClock(); - } - - function renderSecretsAll(options = {}) { - const { renderList: shouldRenderList = true } = options; - renderSecretsStats(); - renderSecretsFetch(); - renderSecretsStatus(); - if (shouldRenderList) { - renderSecretsList(); - } - renderSecretsLiveClock(); - } - - function renderS3All(options = {}) { - const { renderList: shouldRenderList = true } = options; - renderS3Filters(); - renderS3Stats(); - renderS3Fetch(); - renderS3Status(); - if (shouldRenderList) { - renderS3List(); - } - renderS3LiveClock(); - } - - function renderHealthStrip() { - const serviceOrder = [ - { key: "emails", panel: "emails", icon: "โœ‰๏ธ", label: "SES Emails", shortLabel: "SES" }, - { key: "logs", panel: "logs", icon: "๐Ÿ“œ", label: "CloudWatch Logs", shortLabel: "Logs" }, - { key: "secrets", panel: "secrets", icon: "๐Ÿ”", label: "Secrets Manager", shortLabel: "Secrets" }, - { key: "s3", panel: "s3", icon: "๐Ÿชฃ", label: "S3 Explorer", shortLabel: "S3" } - ]; - - el.healthStrip.innerHTML = serviceOrder - .map((service) => { - const entry = healthState.services?.[service.key]; - const toneClass = healthState.loading && !entry ? "warn" : entry?.ok ? "ok" : entry ? "bad" : ""; - const activeClass = service.panel === appState.panel ? "active" : ""; - const className = ["healthBadge", toneClass, activeClass].filter(Boolean).join(" "); - const summary = entry?.summary || (healthState.loading ? "Checking..." : "Waiting"); - const detail = entry?.detail || (healthState.error ? healthState.error : "Not checked yet"); - const updatedMeta = healthState.updatedAt - ? `Updated ${formatRelative(healthState.updatedAt)} via ${healthState.source}` - : ""; - const titleParts = [`${service.label}: ${detail}`]; - - if (updatedMeta) { - titleParts.push(updatedMeta); - } - - return ``; - }) - .join(""); - } - - function renderStats() { - el.totalStat.textContent = String(state.messages.length); - el.visibleStat.textContent = `${state.filtered.length} visible${state.search ? " after search" : ""}`; - el.newStat.textContent = String(state.newIds.size); - el.newestStat.textContent = state.newest ? formatDateTime(state.newest) : "No messages"; - } - - function renderLogsFilters() { - const groups = logsState.groups.length - ? logsState.groups.map((group) => ``) - : ['']; - const streams = [ - '', - ...logsState.streams.map( - (stream) => `` - ) - ]; - - el.logsGroupSelect.innerHTML = groups.join(""); - el.logsStreamSelect.innerHTML = streams.join(""); - - if (logsState.group) { - el.logsGroupSelect.value = logsState.group; - } - - el.logsStreamSelect.value = logsState.stream; - el.logsWrapToggle.checked = logsState.wrapLines; - el.logsTailToggle.checked = logsState.tailNewest; - } - - function renderS3Filters() { - const bucketOptions = s3State.buckets.length - ? s3State.buckets.map( - (bucket) => `` - ) - : ['']; - - el.s3BucketSelect.innerHTML = bucketOptions.join(""); - - if (s3State.bucket) { - el.s3BucketSelect.value = s3State.bucket; - } - - el.s3PrefixInput.value = s3State.prefix; - el.s3SearchInput.value = s3State.search; - } - - function renderLogsStats() { - el.logsTotalStat.textContent = String(logsState.events.length); - el.logsVisibleStat.textContent = `${logsState.filtered.length} visible${logsState.search ? " after search" : ""}`; - el.logsStreamsStat.textContent = String(logsState.streams.length || logsState.searchedLogStreams || 0); - el.logsNewestStat.textContent = logsState.newest ? formatDateTime(logsState.newest) : "No events"; - } - - function renderSecretsStats() { - el.secretsTotalStat.textContent = String(secretsState.items.length); - el.secretsVisibleStat.textContent = `${secretsState.filtered.length} visible${secretsState.search ? " after search" : ""}`; - el.secretsLoadedStat.textContent = String( - Object.values(secretsState.values).filter((entry) => entry?.status === "loaded").length - ); - el.secretsNewestStat.textContent = secretsState.newest ? formatDateTime(secretsState.newest) : "No secrets"; - } - - function renderS3Stats() { - el.s3TotalStat.textContent = String(s3State.objects.length); - el.s3VisibleStat.textContent = `${s3State.filtered.length} visible${s3State.search ? " after search" : ""}`; - el.s3BucketsStat.textContent = String(s3State.buckets.length); - el.s3NewestStat.textContent = s3State.newest ? formatDateTime(s3State.newest) : "No objects"; - } - - function renderFetch() { - if (state.loading) { - el.fetchStat.textContent = "Refreshing..."; - el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; - return; - } - - if (state.error) { - el.fetchStat.textContent = "Needs attention"; - el.fetchDetail.textContent = state.error; - return; - } - - if (!state.updatedAt) { - el.fetchStat.textContent = "Idle"; - el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; - return; - } - - el.fetchStat.textContent = `${state.duration}ms`; - el.fetchDetail.textContent = `${state.parseErrors} parse error${state.parseErrors === 1 ? "" : "s"}. Endpoint: ${config.endpoint}`; - } - - function renderLogsFetch() { - if (logsState.loading) { - el.logsFetchStat.textContent = "Refreshing..."; - el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`; - return; - } - - if (logsState.error) { - el.logsFetchStat.textContent = "Needs attention"; - el.logsFetchDetail.textContent = logsState.error; - return; - } - - if (!logsState.updatedAt) { - el.logsFetchStat.textContent = "Idle"; - el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`; - return; - } - - el.logsFetchStat.textContent = `${logsState.duration}ms`; - el.logsFetchDetail.textContent = `${logsState.group || "No group selected"}. Endpoint: ${config.cloudWatchEndpoint}`; - } - - function renderSecretsFetch() { - if (secretsState.loading) { - el.secretsFetchStat.textContent = "Refreshing..."; - el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; - return; - } - - if (secretsState.error) { - el.secretsFetchStat.textContent = "Needs attention"; - el.secretsFetchDetail.textContent = secretsState.error; - return; - } - - if (!secretsState.updatedAt) { - el.secretsFetchStat.textContent = "Idle"; - el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; - return; - } - - el.secretsFetchStat.textContent = `${secretsState.duration}ms`; - el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; - } - - function renderS3Fetch() { - if (s3State.loading) { - el.s3FetchStat.textContent = "Refreshing..."; - el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`; - return; - } - - if (s3State.error) { - el.s3FetchStat.textContent = "Needs attention"; - el.s3FetchDetail.textContent = s3State.error; - return; - } - - if (!s3State.updatedAt) { - el.s3FetchStat.textContent = "Idle"; - el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`; - return; - } - - el.s3FetchStat.textContent = `${s3State.duration}ms`; - el.s3FetchDetail.textContent = `${s3State.bucket || "No bucket selected"}. Endpoint: ${config.s3Endpoint}`; - } - - function renderStatus() { - el.statusChip.className = "status"; - - if (state.loading) { - el.statusChip.classList.add("warn"); - el.statusChip.textContent = "Refreshing messages..."; - return; - } - - if (state.error) { - el.statusChip.classList.add("bad"); - el.statusChip.textContent = `Refresh failed: ${state.error}`; - return; - } - - if (!state.auto) { - el.statusChip.textContent = "Live refresh paused"; - return; - } - - if (document.hidden) { - el.statusChip.classList.add("warn"); - el.statusChip.textContent = "Tab hidden, live refresh paused"; - return; - } - - if (!state.updatedAt) { - el.statusChip.textContent = "Waiting for first refresh..."; - return; - } - - const seconds = Math.max(0, Math.ceil((state.updatedAt + state.interval - Date.now()) / 1000)); - el.statusChip.classList.add("ok"); - el.statusChip.textContent = `Live refresh on, next check in ${seconds}s`; - } - - function renderLogsStatus() { - el.logsStatusChip.className = "status"; - - if (logsState.loading) { - el.logsStatusChip.classList.add("warn"); - el.logsStatusChip.textContent = "Refreshing logs..."; - return; - } - - if (logsState.error) { - el.logsStatusChip.classList.add("bad"); - el.logsStatusChip.textContent = `Refresh failed: ${logsState.error}`; - return; - } - - if (!logsState.auto) { - el.logsStatusChip.textContent = "Live refresh paused"; - return; - } - - if (document.hidden) { - el.logsStatusChip.classList.add("warn"); - el.logsStatusChip.textContent = "Tab hidden, live refresh paused"; - return; - } - - if (!logsState.updatedAt) { - el.logsStatusChip.textContent = "Waiting for first refresh..."; - return; - } - - const seconds = Math.max(0, Math.ceil((logsState.updatedAt + logsState.interval - Date.now()) / 1000)); - el.logsStatusChip.classList.add("ok"); - el.logsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`; - } - - function renderSecretsStatus() { - el.secretsStatusChip.className = "status"; - - if (secretsState.loading) { - el.secretsStatusChip.classList.add("warn"); - el.secretsStatusChip.textContent = "Refreshing secrets..."; - return; - } - - if (secretsState.error) { - el.secretsStatusChip.classList.add("bad"); - el.secretsStatusChip.textContent = `Refresh failed: ${secretsState.error}`; - return; - } - - if (!secretsState.auto) { - el.secretsStatusChip.textContent = "Live refresh paused"; - return; - } - - if (document.hidden) { - el.secretsStatusChip.classList.add("warn"); - el.secretsStatusChip.textContent = "Tab hidden, live refresh paused"; - return; - } - - if (!secretsState.updatedAt) { - el.secretsStatusChip.textContent = "Waiting for first refresh..."; - return; - } - - const seconds = Math.max(0, Math.ceil((secretsState.updatedAt + secretsState.interval - Date.now()) / 1000)); - el.secretsStatusChip.classList.add("ok"); - el.secretsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`; - } - - function renderS3Status() { - el.s3StatusChip.className = "status"; - - if (s3State.loading) { - el.s3StatusChip.classList.add("warn"); - el.s3StatusChip.textContent = "Refreshing objects..."; - return; - } - - if (s3State.error) { - el.s3StatusChip.classList.add("bad"); - el.s3StatusChip.textContent = `Refresh failed: ${s3State.error}`; - return; - } - - if (!s3State.auto) { - el.s3StatusChip.textContent = "Live refresh paused"; - return; - } - - if (document.hidden) { - el.s3StatusChip.classList.add("warn"); - el.s3StatusChip.textContent = "Tab hidden, live refresh paused"; - return; - } - - if (!s3State.updatedAt) { - el.s3StatusChip.textContent = "Waiting for first refresh..."; - return; - } - - const seconds = Math.max(0, Math.ceil((s3State.updatedAt + s3State.interval - Date.now()) / 1000)); - el.s3StatusChip.classList.add("ok"); - el.s3StatusChip.textContent = `Live refresh on, next check in ${seconds}s`; - } - - function renderLiveClock() { - if (!state.updatedAt) { - el.updatedStat.textContent = "Not refreshed yet"; - return; - } - - el.updatedStat.textContent = `Updated ${formatRelative(state.updatedAt)} via ${state.source}`; - renderStatus(); - } - - function renderLogsLiveClock() { - if (!logsState.updatedAt) { - el.logsUpdatedStat.textContent = "Not refreshed yet"; - return; - } - - el.logsUpdatedStat.textContent = `Updated ${formatRelative(logsState.updatedAt)} via ${logsState.source}`; - renderLogsStatus(); - } - - function renderSecretsLiveClock() { - if (!secretsState.updatedAt) { - el.secretsUpdatedStat.textContent = "Not refreshed yet"; - return; - } - - el.secretsUpdatedStat.textContent = `Updated ${formatRelative(secretsState.updatedAt)} via ${secretsState.source}`; - renderSecretsStatus(); - } - - function renderS3LiveClock() { - if (!s3State.updatedAt) { - el.s3UpdatedStat.textContent = "Not refreshed yet"; - return; - } - - el.s3UpdatedStat.textContent = `Updated ${formatRelative(s3State.updatedAt)} via ${s3State.source}`; - renderS3Status(); - } - - function renderList() { - el.banner.hidden = !state.error; - el.banner.textContent = state.error ? `Refresh failed: ${state.error}` : ""; - - if (!state.filtered.length) { - el.list.innerHTML = ""; - el.empty.hidden = false; - el.empty.textContent = state.messages.length - ? "No messages match the current search." - : "No emails yet. Send one through LocalStack SES and refresh."; - updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); - return; - } - - el.empty.hidden = true; - el.list.innerHTML = state.filtered.map(renderCard).join(""); - bindCardToggles(); - syncCardExpansion(); - updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); - } - - function renderLogsList() { - el.logsBanner.hidden = !logsState.error; - el.logsBanner.textContent = logsState.error ? `Refresh failed: ${logsState.error}` : ""; - - if (!logsState.group && !logsState.groups.length) { - el.logsList.innerHTML = ""; - el.logsEmpty.hidden = false; - el.logsEmpty.textContent = "No CloudWatch log groups found in LocalStack yet."; - updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); - return; - } - - if (!logsState.filtered.length) { - el.logsList.innerHTML = ""; - el.logsEmpty.hidden = false; - el.logsEmpty.textContent = logsState.events.length - ? "No log events match the current search." - : "No log events found for the selected group, stream, and time window."; - updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); - return; - } - - el.logsEmpty.hidden = true; - el.logsList.innerHTML = logsState.filtered.map((event) => renderLogEvent(event)).join(""); - bindLogToggles(); - updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); - } - - function renderSecretsList() { - el.secretsBanner.hidden = !secretsState.error; - el.secretsBanner.textContent = secretsState.error ? `Refresh failed: ${secretsState.error}` : ""; - - if (!secretsState.filtered.length) { - el.secretsList.innerHTML = ""; - el.secretsEmpty.hidden = false; - el.secretsEmpty.textContent = secretsState.items.length - ? "No secrets match the current search." - : "No Secrets Manager entries found in LocalStack yet."; - updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); - return; - } - - el.secretsEmpty.hidden = true; - el.secretsList.innerHTML = secretsState.filtered.map((secret) => renderSecretCard(secret)).join(""); - bindSecretToggles(); - syncSecretExpansion(); - updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); - } - - function renderS3List() { - el.s3Banner.hidden = !s3State.error; - el.s3Banner.textContent = s3State.error ? `Refresh failed: ${s3State.error}` : ""; - - if (!s3State.bucket && !s3State.buckets.length) { - el.s3List.innerHTML = ""; - el.s3Empty.hidden = false; - el.s3Empty.textContent = "No S3 buckets found in LocalStack yet."; - updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); - return; - } - - if (!s3State.filtered.length) { - el.s3List.innerHTML = ""; - el.s3Empty.hidden = false; - el.s3Empty.textContent = s3State.objects.length - ? "No S3 objects match the current search." - : "No S3 objects found for the selected bucket and prefix."; - updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); - return; - } - - el.s3Empty.hidden = true; - el.s3List.innerHTML = s3State.filtered.map((object) => renderS3ObjectCard(object)).join(""); - bindS3Toggles(); - syncS3Expansion(); - updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); - } - - function renderLogEvent(event) { - const level = detectLogLevel(event); - const levelTag = level ? `${escapeHtml(level.label)}` : ""; - - return ` -
- -
-
- ${escapeHtml(formatDateTime(event.timestamp))} - ${escapeHtml(event.logStreamName || "Unknown stream")} - ${levelTag} -
-
- - ${escapeHtml(formatRelative(event.timestamp || Date.now()))} -
-
-

${renderLogPreviewContent(event)}

-
-
-
${renderLogBodyContent(event.message)}
-
-
- `; - } - - function renderSecretCard(secret) { - const valueState = secretsState.values[secret.id]; - const tags = []; - - if (secret.owningService) { - tags.push(`${escapeHtml(secret.owningService)}`); - } - - if (secret.primaryRegion) { - tags.push(`${escapeHtml(secret.primaryRegion)}`); - } - - if (secret.rotationEnabled) { - tags.push('Rotation on'); - } else { - tags.push('Rotation off'); - } - - if (secret.tagCount) { - tags.push(`${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}`); - } - - if (valueState?.status === "loaded") { - tags.push(`${escapeHtml(valueState.label)}`); - } - - if (valueState?.status === "error") { - tags.push('Value load failed'); - } - - return ` -
- -
-
-

๐Ÿ” ${escapeHtml(secret.name)}

-

${escapeHtml(secret.description || secret.arn || "No description")}

-
- ${escapeHtml(formatDateTime(secret.lastChangedDate || secret.createdDate))} -
-
${tags.join("")}
-

${escapeHtml(buildSecretPreview(secret))}

-
-
-
- ${metaCard("Name", secret.name)} - ${metaCard("ARN", secret.arn || "Not available")} - ${metaCard("Description", secret.description || "None")} - ${metaCard("Last changed", formatDateTime(secret.lastChangedDate || secret.createdDate))} - ${metaCard("Created", formatDateTime(secret.createdDate))} - ${metaCard("Last accessed", formatDateTime(secret.lastAccessedDate))} - ${metaCard("Primary region", secret.primaryRegion || "Not set")} - ${metaCard("Owning service", secret.owningService || "Not set")} -
- -
- ${renderSecretValuePanel(secret, valueState)} -
-
-
- `; - } - - function renderS3ObjectCard(object) { - const previewState = s3State.previews[object.id]; - const tags = [ - `${escapeHtml(object.bucket)}`, - `${escapeHtml(formatBytes(object.size))}` - ]; - - if (object.storageClass) { - tags.push(`${escapeHtml(object.storageClass)}`); - } - - if (previewState?.status === "loaded") { - tags.push(`${escapeHtml(previewState.previewType)}`); - } - - if (previewState?.status === "error") { - tags.push('Preview failed'); - } - - return ` -
- -
-
-

๐Ÿชฃ ${escapeHtml(object.key)}

-

${escapeHtml(object.bucket)} โ€ข ${escapeHtml(formatBytes(object.size))}

-
- ${escapeHtml(formatDateTime(object.lastModified))} -
-
${tags.join("")}
-

${escapeHtml(buildS3Preview(object))}

-
-
-
-
- - - โฌ‡๏ธ Download -
-
- -
- ${metaCard("Bucket", object.bucket)} - ${metaCard("Key", object.key)} - ${metaCard("Size", formatBytes(object.size))} - ${metaCard("Modified", formatDateTime(object.lastModified))} - ${metaCard("Storage class", object.storageClass || "STANDARD")} - ${metaCard("ETag", object.etag || "Not available")} -
- -
${renderS3PreviewPanel(object, previewState)}
-
-
- `; - } - - function bindLogToggles() { - el.logsList.querySelectorAll(".logEvent").forEach((details) => { - details.addEventListener("toggle", () => { - const id = details.dataset.id; - - if (!id) { - return; - } - - if (details.open) { - logsState.openIds.add(id); - } else { - logsState.openIds.delete(id); - } - }); - }); - } - - function bindSecretToggles() { - el.secretsList.querySelectorAll(".secretCard").forEach((details) => { - details.addEventListener("toggle", () => { - const id = details.dataset.id; - - if (!id) { - return; - } - - if (details.open) { - secretsState.openIds.add(id); - ensureSecretValue(id, { force: false }); - } else { - secretsState.openIds.delete(id); - } - }); - }); - } - - function bindS3Toggles() { - el.s3List.querySelectorAll(".s3Card").forEach((details) => { - details.addEventListener("toggle", () => { - const id = details.dataset.id; - - if (!id) { - return; - } - - if (details.open) { - s3State.openIds.add(id); - ensureS3Preview(id, { force: false }); - } else { - s3State.openIds.delete(id); - } - }); - }); - } - - function bindCardToggles() { - el.list.querySelectorAll(".card").forEach((details) => { - details.addEventListener("toggle", () => { - const id = details.dataset.id; - - if (!id) { - return; - } - - if (details.open) { - state.openIds.add(id); - } else { - state.openIds.delete(id); - } - - hydrate(details, getMessage(id)); - }); - }); - } - - function renderCard(message) { - const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); - const tags = []; - - if (state.newIds.has(message.id)) { - tags.push('New'); - } - - if (message.attachmentCount) { - tags.push( - `${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}` - ); - } - - tags.push(`${message.hasHtml ? "HTML" : "Text only"}`); - - if (message.parseError) { - tags.push('Parse issue'); - } - - return ` -
- -
-
-

${escapeHtml(message.subject)}

-

${escapeHtml(message.from)} to ${escapeHtml(message.to)}

-
- ${escapeHtml(formatDateTime(message.timestamp))} -
-
${tags.join("")}
-

${escapeHtml(message.preview)}

-
-
-
-
- ${ - message.hasHtml - ? `` - : "" - } - - -
-
- -
-
- -
- ${metaCard("From", message.from)} - ${metaCard("To", message.to)} - ${metaCard("Reply-To", message.replyTo || "None")} - ${metaCard("Sent", formatDateTime(message.timestamp))} - ${metaCard("Region", message.region || "Unknown region")} - ${metaCard("LocalStack Id", message.id)} - ${metaCard("Message-Id", message.messageId || "Not available")} - ${metaCard("Raw size", formatBytes(message.rawSizeBytes))} - ${message.parseError ? metaCard("Parse error", message.parseError) : ""} -
- - ${ - message.attachments?.length - ? `
${message.attachments - .map((attachment) => { - const size = attachment.size ? `, ${formatBytes(attachment.size)}` : ""; - const icon = resolveAttachmentIcon(attachment); - return `${icon} ${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})`; - }) - .join("")}
` - : "" - } - -
${renderPanel(message, view)}
-
-
- `; - } - - function renderPanel(message, view) { - if (view === "rendered" && message.hasHtml) { - return ``; - } - - if (view === "raw") { - const raw = state.raw[message.id]; - - if (!raw) { - return `
Raw MIME source is loaded on demand.
`; - } - - if (raw.status === "loading") { - return '
Loading raw source...
'; - } - - if (raw.status === "error") { - return `
Unable to load raw source: ${escapeHtml(raw.error)}
`; - } - - return `
${escapeHtml(raw.value)}
`; - } - - return `
${escapeHtml(message.textContent || "No plain-text content available for this message.")}
`; - } - - function renderSecretValuePanel(secret, valueState) { - if (!valueState) { - return `
Secret values are loaded on demand.
`; - } - - if (valueState.status === "loading") { - return '
Loading secret value...
'; - } - - if (valueState.status === "error") { - return `
Unable to load secret value: ${escapeHtml(valueState.error)}
`; - } - - const revealed = secretsState.revealedIds.has(secret.id); - const maskedHtml = escapeHtml(maskSecretValue(valueState.copyValue)); - - return ` -
-
- ${escapeHtml(valueState.label)} - ${ - valueState.versionId - ? `Version ${escapeHtml(valueState.versionId.slice(0, 8))}` - : "" - } - ${ - valueState.versionStages.length - ? `${escapeHtml(valueState.versionStages.join(", "))}` - : "" - } -
-
- - - - - - -
-
-
${revealed ? valueState.displayHtml : maskedHtml}
- `; - } - - function renderS3PreviewPanel(object, previewState) { - if (!previewState) { - return `
Object previews load on demand.
`; - } - - if (previewState.status === "loading") { - return '
Loading object preview...
'; - } - - if (previewState.status === "error") { - return `
Unable to load object preview: ${escapeHtml(previewState.error)}
`; - } - - const truncatedTag = previewState.truncated ? 'Preview truncated' : ""; - let previewContent = `
No inline preview available for this object type.
`; - - if (previewState.previewType === "image" && previewState.imageDataUrl) { - previewContent = `${escapeHtml(object.key)}`; - } else if (previewState.previewType === "json") { - previewContent = `
${highlightJsonText(prettyJsonOrText(previewState.previewText))}
`; - } else if (previewState.previewType === "text" || previewState.previewType === "html") { - previewContent = `
${escapeHtml(previewState.previewText || "No preview text available.")}
`; - } - - return ` -
-
- ${escapeHtml(previewState.previewType)} - ${truncatedTag} - ${previewState.contentType ? `${escapeHtml(previewState.contentType)}` : ""} -
-
- - โฌ‡๏ธ Download -
-
- ${previewContent} - `; - } - - function metaCard(label, value) { - return `
${escapeHtml(label)}
${escapeHtml(value)}
`; - } - - function syncCardExpansion() { - const applyCardState = () => { - el.list.querySelectorAll(".card").forEach((details) => { - const id = details.dataset.id; - const shouldOpen = Boolean(id && state.openIds.has(id)); - - if (shouldOpen && !details.open) { - details.open = true; - } - - if (!shouldOpen && details.open) { - details.open = false; - return; - } - - if (shouldOpen) { - hydrate(details, getMessage(id)); - } - }); - }; - - applyCardState(); - window.requestAnimationFrame(applyCardState); - } - - function syncSecretExpansion() { - const applySecretState = () => { - el.secretsList.querySelectorAll(".secretCard").forEach((details) => { - const id = details.dataset.id; - const shouldOpen = Boolean(id && secretsState.openIds.has(id)); - - if (shouldOpen && !details.open) { - details.open = true; - } - - if (!shouldOpen && details.open) { - details.open = false; - return; - } - - if (shouldOpen) { - ensureSecretValue(id, { force: false }); - } - }); - }; - - applySecretState(); - window.requestAnimationFrame(applySecretState); - } - - function syncS3Expansion() { - const applyS3State = () => { - el.s3List.querySelectorAll(".s3Card").forEach((details) => { - const id = details.dataset.id; - const shouldOpen = Boolean(id && s3State.openIds.has(id)); - - if (shouldOpen && !details.open) { - details.open = true; - } - - if (!shouldOpen && details.open) { - details.open = false; - return; - } - - if (shouldOpen) { - ensureS3Preview(id, { force: false }); - } - }); - }; - - applyS3State(); - window.requestAnimationFrame(applyS3State); - } - - function resolveAttachmentIcon(attachment) { - const filename = String(attachment?.filename || "").toLowerCase(); - const contentType = String(attachment?.contentType || "").toLowerCase(); - - if (filename.endsWith(".pdf") || contentType.includes("pdf")) { - return "๐Ÿ“„"; - } - - if ( - [".doc", ".docx", ".txt", ".rtf", ".md"].some((extension) => filename.endsWith(extension)) || - contentType.includes("word") || - contentType.startsWith("text/") - ) { - return "๐Ÿ“"; - } - - if ( - [".xls", ".xlsx", ".csv"].some((extension) => filename.endsWith(extension)) || - contentType.includes("sheet") || - contentType.includes("csv") - ) { - return "๐Ÿ“Š"; - } - - if ( - filename.endsWith(".json") || - filename.endsWith(".xml") || - filename.endsWith(".yaml") || - filename.endsWith(".yml") - ) { - return "๐Ÿงพ"; - } - - if (contentType.startsWith("image/")) { - return "๐Ÿ–ผ๏ธ"; - } - - if (contentType.startsWith("audio/")) { - return "๐ŸŽต"; - } - - if (contentType.startsWith("video/")) { - return "๐ŸŽฌ"; - } - - if ( - [".zip", ".rar", ".7z", ".tar", ".gz"].some((extension) => filename.endsWith(extension)) || - contentType.includes("zip") || - contentType.includes("compressed") - ) { - return "๐Ÿ—œ๏ธ"; - } - - return "๐Ÿ“Ž"; - } - - function hydrate(details, message) { - if (!details || !details.open || !message) { - return; - } - - const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); - - if (view !== "rendered" || !message.hasHtml) { - return; - } - - const iframe = details.querySelector("[data-frame]"); - - if (iframe) { - iframe.referrerPolicy = "no-referrer"; - iframe.sandbox = ""; - iframe.srcdoc = message.renderedHtml || ""; - } - } - - function getMessage(id) { - return state.messages.find((message) => message.id === id); - } - - function getLogEvent(id) { - return logsState.events.find((event) => event.id === id); - } - - function getSecret(id) { - return secretsState.items.find((secret) => secret.id === id); - } - - function getS3Object(id) { - return s3State.objects.find((object) => object.id === id); - } - - async function loadRaw(id) { - if (state.raw[id]?.status === "loaded") { - return state.raw[id].value; - } - - if (state.raw[id]?.status === "loading") { - return null; - } - - state.raw[id] = { status: "loading" }; - renderList(); - - try { - const response = await fetch(`/api/messages/${encodeURIComponent(id)}/raw`, { cache: "no-store" }); - - if (!response.ok) { - throw new Error((await response.text()) || `Request failed with ${response.status}`); - } - - const value = await response.text(); - state.raw[id] = { status: "loaded", value }; - return value; - } catch (error) { - state.raw[id] = { status: "error", error: error.message || "Unknown raw load error" }; - setStatus("Could not load the raw message source.", "bad"); - return null; - } finally { - renderList(); - } - } - - async function ensureSecretValue(id, options = {}) { - const { force = false } = options; - - if (!id) { - return null; - } - - if (!force && secretsState.values[id]?.status === "loaded") { - return secretsState.values[id]; - } - - if (secretsState.values[id]?.status === "loading") { - return null; - } - - secretsState.values[id] = { status: "loading" }; - renderSecretsAll(); - - try { - const response = await fetch(`/api/secrets/value?id=${encodeURIComponent(id)}`, { cache: "no-store" }); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - const secretString = typeof payload.secretString === "string" ? payload.secretString : ""; - const secretBinary = typeof payload.secretBinary === "string" ? payload.secretBinary : ""; - const parsedString = secretString ? tryParseJsonText(secretString) : { ok: false, value: null }; - const entry = { - status: "loaded", - label: secretBinary ? "Binary" : parsedString.ok ? "JSON" : secretString ? "Text" : "Empty", - copyValue: secretBinary - ? secretBinary - : parsedString.ok - ? JSON.stringify(parsedString.value, null, 2) - : secretString || "No secret value.", - displayHtml: secretBinary - ? escapeHtml(secretBinary) - : parsedString.ok - ? highlightJsonText(JSON.stringify(parsedString.value, null, 2)) - : escapeHtml(secretString || "No secret value."), - isJson: parsedString.ok, - isBinary: Boolean(secretBinary), - versionId: payload.versionId || "", - versionStages: Array.isArray(payload.versionStages) ? payload.versionStages : [], - createdDate: payload.createdDate || "", - arn: payload.arn || "", - name: payload.name || "" - }; - - secretsState.values[id] = entry; - return entry; - } catch (error) { - secretsState.values[id] = { - status: "error", - error: error.message || "Unknown secret value error" - }; - setSecretsStatus("Could not load the secret value.", "bad"); - return null; - } finally { - renderSecretsAll(); - } - } - - async function ensureS3Preview(id, options = {}) { - const { force = false } = options; - - if (!id) { - return null; - } - - if (!force && s3State.previews[id]?.status === "loaded") { - return s3State.previews[id]; - } - - if (s3State.previews[id]?.status === "loading") { - return null; - } - - const object = getS3Object(id); - - if (!object) { - return null; - } - - s3State.previews[id] = { status: "loading" }; - renderS3All(); - - try { - const response = await fetch( - `/api/s3/object?bucket=${encodeURIComponent(object.bucket)}&key=${encodeURIComponent(object.key)}`, - { cache: "no-store" } - ); - - if (!response.ok) { - const payload = await safeJson(response); - throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); - } - - const payload = await response.json(); - const entry = { - status: "loaded", - previewType: payload.previewType || "binary", - previewText: payload.previewText || "", - imageDataUrl: payload.imageDataUrl || "", - contentType: payload.contentType || "", - contentLength: payload.contentLength || 0, - truncated: Boolean(payload.truncated), - metadata: payload.metadata || {} - }; - - s3State.previews[id] = entry; - return entry; - } catch (error) { - s3State.previews[id] = { - status: "error", - error: error.message || "Unknown S3 preview error" - }; - setS3Status("Could not load the S3 object preview.", "bad"); - return null; - } finally { - renderS3All(); - } - } - - async function copyText(value) { - try { - await navigator.clipboard.writeText(value); - } catch { - const input = document.createElement("textarea"); - input.value = value; - input.setAttribute("readonly", ""); - input.style.position = "fixed"; - input.style.opacity = "0"; - document.body.appendChild(input); - input.select(); - document.execCommand("copy"); - document.body.removeChild(input); - } - } - - function setStatus(message, tone) { - el.statusChip.className = "status"; - - if (tone) { - el.statusChip.classList.add(tone); - } - - el.statusChip.textContent = message; - } - - function setLogsStatus(message, tone) { - el.logsStatusChip.className = "status"; - - if (tone) { - el.logsStatusChip.classList.add(tone); - } - - el.logsStatusChip.textContent = message; - } - - function setSecretsStatus(message, tone) { - el.secretsStatusChip.className = "status"; - - if (tone) { - el.secretsStatusChip.classList.add(tone); - } - - el.secretsStatusChip.textContent = message; - } - - function setS3Status(message, tone) { - el.s3StatusChip.className = "status"; - - if (tone) { - el.s3StatusChip.classList.add(tone); - } - - el.s3StatusChip.textContent = message; - } - - function getInitialPanel() { - const storedPanel = readStoredValue(PANEL_STORAGE_KEY); - return ["emails", "logs", "secrets", "s3"].includes(storedPanel) ? storedPanel : "emails"; - } - - function getInitialTheme() { - const storedTheme = readStoredValue(THEME_STORAGE_KEY); - - if (storedTheme === "dark" || storedTheme === "light") { - return storedTheme; - } - - return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"; - } - - function applyTheme(theme) { - const nextTheme = theme === "dark" ? "dark" : "light"; - appState.theme = nextTheme; - document.body.dataset.theme = nextTheme; - - try { - window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); - } catch {} - - renderThemeToggle(); - } - - function renderThemeToggle() { - if (!el.themeToggle) { - return; - } - - const isDark = appState.theme === "dark"; - el.themeToggle.textContent = isDark ? "๐ŸŒ™ Dark theme" : "โ˜€๏ธ Light theme"; - el.themeToggle.setAttribute("aria-pressed", isDark ? "true" : "false"); - el.themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme"); - el.themeToggle.title = isDark ? "Switch to light theme" : "Switch to dark theme"; - } - - function persistPanel() { - writeStoredValue(PANEL_STORAGE_KEY, appState.panel); - } - - function persistEmailPreferences() { - writeStoredJson(EMAIL_PREFERENCES_STORAGE_KEY, { - search: state.search, - auto: state.auto, - interval: state.interval - }); - } - - function persistLogPreferences() { - writeStoredJson(LOG_PREFERENCES_STORAGE_KEY, { - group: logsState.group, - stream: logsState.stream, - search: logsState.search, - auto: logsState.auto, - interval: logsState.interval, - windowMs: logsState.windowMs, - limit: logsState.limit, - wrapLines: logsState.wrapLines, - tailNewest: logsState.tailNewest - }); - } - - function persistSecretPreferences() { - writeStoredJson(SECRET_PREFERENCES_STORAGE_KEY, { - search: secretsState.search, - auto: secretsState.auto, - interval: secretsState.interval - }); - } - - function persistS3Preferences() { - writeStoredJson(S3_PREFERENCES_STORAGE_KEY, { - bucket: s3State.bucket, - prefix: s3State.prefix, - search: s3State.search, - auto: s3State.auto, - interval: s3State.interval - }); - } - - function resetSavedState() { - [ - THEME_STORAGE_KEY, - PANEL_STORAGE_KEY, - EMAIL_PREFERENCES_STORAGE_KEY, - LOG_PREFERENCES_STORAGE_KEY, - SECRET_PREFERENCES_STORAGE_KEY, - S3_PREFERENCES_STORAGE_KEY - ].forEach((key) => { - try { - window.localStorage.removeItem(key); - } catch {} - }); - - window.location.reload(); - } - - function getStoredPreferences(key) { - try { - const rawValue = window.localStorage.getItem(key); - - if (!rawValue) { - return null; - } - - const parsedValue = JSON.parse(rawValue); - return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) ? parsedValue : null; - } catch { - return null; - } - } - - function readStoredValue(key) { - try { - return window.localStorage.getItem(key); - } catch { - return null; - } - } - - function writeStoredValue(key, value) { - try { - window.localStorage.setItem(key, String(value)); - } catch {} - } - - function writeStoredJson(key, value) { - try { - window.localStorage.setItem(key, JSON.stringify(value)); - } catch {} - } - - function getStoredText(value, fallback = "") { - return typeof value === "string" ? value : fallback; - } - - function getStoredBoolean(value, fallback) { - return typeof value === "boolean" ? value : fallback; - } - - function getStoredNumber(value, allowedValues, fallback) { - const normalizedValue = Number(value); - return Number.isFinite(normalizedValue) && allowedValues.includes(normalizedValue) ? normalizedValue : fallback; - } - - function scrollPaneToTop(element) { - if (!element) { - return; - } - - element.scrollTo({ top: 0, behavior: "smooth" }); - } - - function updatePaneTopButtonVisibility(pane, button) { - if (!pane || !button) { - return; - } - - button.classList.toggle("visible", pane.scrollTop > 140); - } - - function formatDateTime(value) { - if (!value) { - return "Unknown time"; - } - - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? "Unknown time" - : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); - } - - function formatRelative(timestampMs) { - const seconds = Math.max(1, Math.round((Date.now() - timestampMs) / 1000)); - - if (seconds < 60) { - return `${seconds}s ago`; - } - - const minutes = Math.round(seconds / 60); - - if (minutes < 60) { - return `${minutes}m ago`; - } - - const hours = Math.round(minutes / 60); - - if (hours < 24) { - return `${hours}h ago`; - } - - return `${Math.round(hours / 24)}d ago`; - } - - function formatBytes(value) { - if (!value) { - return "0 B"; - } - - const units = ["B", "KB", "MB", "GB"]; - let size = value; - let index = 0; - - while (size >= 1024 && index < units.length - 1) { - size /= 1024; - index += 1; - } - - return `${index === 0 ? size : size.toFixed(1)} ${units[index]}`; - } - - function formatLogMessage(message) { - const value = String(message || "").trim(); - - if (!value) { - return "No log payload."; - } - - try { - return JSON.stringify(JSON.parse(value), null, 2); - } catch { - return value; - } - } - - function buildSecretPreview(secret) { - const fragments = []; - - if (secret.description) { - fragments.push(secret.description); - } - - if (secret.owningService) { - fragments.push(`Service: ${secret.owningService}`); - } - - if (secret.primaryRegion) { - fragments.push(`Region: ${secret.primaryRegion}`); - } - - if (secret.tagCount) { - fragments.push(`${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}`); - } - - if (!fragments.length) { - fragments.push("No description or tags yet."); - } - - return fragments.join(" โ€ข "); - } - - function toggleSecretReveal(id) { - if (secretsState.revealedIds.has(id)) { - secretsState.revealedIds.delete(id); - } else { - secretsState.revealedIds.add(id); - } - - renderSecretsAll(); - } - - function maskSecretValue(value) { - const source = String(value || ""); - - if (!source) { - return "Secret is loaded but empty."; - } - - const lines = source.split("\n"); - return lines.map((line) => "โ€ข".repeat(Math.max(12, Math.min(line.length || 0, 48)))).join("\n"); - } - - function buildS3Preview(object) { - const fragments = []; - - if (object.storageClass) { - fragments.push(object.storageClass); - } - - fragments.push(formatBytes(object.size)); - - if (object.etag) { - fragments.push(`ETag ${object.etag.slice(0, 12)}`); - } - - return fragments.join(" โ€ข "); - } - - function prettyJsonOrText(value) { - const parsed = tryParseJsonText(value); - return parsed.ok ? JSON.stringify(parsed.value, null, 2) : String(value || ""); - } - - function detectLogLevel(event) { - const parsed = tryParseJsonText(event?.message); - const candidates = parsed.ok - ? [parsed.value?.level, parsed.value?.severity, parsed.value?.logLevel, parsed.value?.status, parsed.value?.lvl] - : [String(event?.message || "").match(/\b(error|warn|warning|info|debug|trace|fatal)\b/i)?.[0] || ""]; - const normalized = String(candidates.find(Boolean) || "").toLowerCase(); - - if (["fatal", "error", "critical"].includes(normalized)) { - return { label: normalized.toUpperCase(), className: "levelError" }; - } - - if (["warn", "warning"].includes(normalized)) { - return { label: "WARN", className: "levelWarn" }; - } - - if (["info", "notice"].includes(normalized)) { - return { label: normalized.toUpperCase(), className: "levelInfo" }; - } - - if (["debug", "trace"].includes(normalized)) { - return { label: normalized.toUpperCase(), className: "levelDebug" }; - } - - return null; - } - - function renderLogPreviewContent(event) { - const parsedLog = tryParseJsonText(event?.message); - - if (!parsedLog.ok) { - return escapeHtml(event?.preview || "No preview available."); - } - - const compactJson = JSON.stringify(parsedLog.value); - const previewText = compactJson.length > 220 ? `${compactJson.slice(0, 217)}...` : compactJson; - return highlightJsonText(previewText); - } - - function renderLogBodyContent(message) { - const parsedLog = tryParseJsonText(message); - - if (!parsedLog.ok) { - return escapeHtml(formatLogMessage(message)); - } - - return highlightJsonText(JSON.stringify(parsedLog.value, null, 2)); - } - - function tryParseJsonText(message) { - const value = String(message || "").trim(); - - if (!value) { - return { ok: false, value: null }; - } - - try { - return { ok: true, value: JSON.parse(value) }; - } catch { - return { ok: false, value: null }; - } - } - - function highlightJsonText(value) { - const source = String(value ?? ""); - const tokenRegex = - /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g; - let html = ""; - let lastIndex = 0; - - for (const match of source.matchAll(tokenRegex)) { - const [token] = match; - const index = match.index ?? 0; - let className = "jsonNumber"; - - html += escapeHtml(source.slice(lastIndex, index)); - - if (token.endsWith(":")) { - className = "jsonKey"; - } else if (token === "true" || token === "false") { - className = "jsonBoolean"; - } else if (token === "null") { - className = "jsonNull"; - } else if (token.startsWith('"')) { - className = "jsonString"; - } - - html += `${escapeHtml(token)}`; - lastIndex = index + token.length; - } - - html += escapeHtml(source.slice(lastIndex)); - return html; - } - - function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - async function safeJson(response) { - try { - return await response.json(); - } catch { - return null; - } - } -} - app.listen(PORT, () => { console.log(`LocalStack inspector is running on http://localhost:${PORT}`); console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`); diff --git a/_reference/localEmailViewer/package.json b/_reference/localEmailViewer/package.json index 956794f4c..6972c7f95 100644 --- a/_reference/localEmailViewer/package.json +++ b/_reference/localEmailViewer/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "node index.js", - "check": "node --check index.js" + "check": "node --check index.js && node --check public/client-app.js && node --check server/config.js && node --check server/localstack-service.js && node --check server/page.js" }, "keywords": [], "author": "", diff --git a/_reference/localEmailViewer/public/client-app.js b/_reference/localEmailViewer/public/client-app.js new file mode 100644 index 000000000..2e446c8fe --- /dev/null +++ b/_reference/localEmailViewer/public/client-app.js @@ -0,0 +1,3154 @@ +function clientApp(config) { + const THEME_STORAGE_KEY = "localstack-inspector-theme"; + const PANEL_STORAGE_KEY = "localstack-inspector-panel"; + const EMAIL_PREFERENCES_STORAGE_KEY = "localstack-inspector-email-preferences"; + const LOG_PREFERENCES_STORAGE_KEY = "localstack-inspector-log-preferences"; + const SECRET_PREFERENCES_STORAGE_KEY = "localstack-inspector-secret-preferences"; + const S3_PREFERENCES_STORAGE_KEY = "localstack-inspector-s3-preferences"; + const HEALTH_REFRESH_MS = 30000; + const REFRESH_INTERVALS = [5000, 10000, 15000, 30000, 60000]; + const LOG_WINDOWS = [300000, 900000, 3600000, 21600000, 86400000]; + const LOG_LIMITS = [100, 200, 300, 500]; + const storedEmailPreferences = getStoredPreferences(EMAIL_PREFERENCES_STORAGE_KEY); + const storedLogPreferences = getStoredPreferences(LOG_PREFERENCES_STORAGE_KEY); + const storedSecretPreferences = getStoredPreferences(SECRET_PREFERENCES_STORAGE_KEY); + const storedS3Preferences = getStoredPreferences(S3_PREFERENCES_STORAGE_KEY); + + const appState = { + panel: getInitialPanel(), + logsReady: false, + secretsReady: false, + s3Ready: false, + theme: getInitialTheme() + }; + + const state = { + messages: [], + filtered: [], + search: getStoredText(storedEmailPreferences?.search), + auto: getStoredBoolean(storedEmailPreferences?.auto, true), + interval: getStoredNumber(storedEmailPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + parseErrors: 0, + newest: "", + newIds: new Set(), + knownIds: new Set(), + openIds: new Set(), + views: {}, + raw: {}, + listSignature: "" + }; + + const logsState = { + groups: [], + streams: [], + events: [], + filtered: [], + group: getStoredText(storedLogPreferences?.group, config.defaultLogGroup || ""), + stream: getStoredText(storedLogPreferences?.stream), + search: getStoredText(storedLogPreferences?.search), + auto: getStoredBoolean(storedLogPreferences?.auto, true), + interval: getStoredNumber(storedLogPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + windowMs: getStoredNumber(storedLogPreferences?.windowMs, LOG_WINDOWS, config.defaultLogWindowMs), + limit: getStoredNumber(storedLogPreferences?.limit, LOG_LIMITS, config.defaultLogLimit), + wrapLines: getStoredBoolean(storedLogPreferences?.wrapLines, true), + tailNewest: getStoredBoolean(storedLogPreferences?.tailNewest, false), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + newest: 0, + searchedLogStreams: 0, + openIds: new Set(), + listSignature: "" + }; + + const secretsState = { + items: [], + filtered: [], + search: getStoredText(storedSecretPreferences?.search), + auto: getStoredBoolean(storedSecretPreferences?.auto, true), + interval: getStoredNumber(storedSecretPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + newest: "", + openIds: new Set(), + values: {}, + revealedIds: new Set(), + listSignature: "" + }; + + const s3State = { + buckets: [], + objects: [], + filtered: [], + bucket: getStoredText(storedS3Preferences?.bucket, config.defaultS3Bucket || ""), + prefix: getStoredText(storedS3Preferences?.prefix), + search: getStoredText(storedS3Preferences?.search), + auto: getStoredBoolean(storedS3Preferences?.auto, true), + interval: getStoredNumber(storedS3Preferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs), + loading: false, + error: "", + updatedAt: 0, + source: "initial", + duration: 0, + newest: "", + openIds: new Set(), + previews: {}, + listSignature: "" + }; + + const healthState = { + services: {}, + loading: false, + error: "", + updatedAt: 0, + source: "initial" + }; + + const el = { + themeToggle: document.getElementById("themeToggle"), + resetStateButton: document.getElementById("resetStateButton"), + healthRefreshButton: document.getElementById("healthRefreshButton"), + healthStrip: document.getElementById("healthStrip"), + emailsPanel: document.getElementById("emailsPanel"), + logsPanel: document.getElementById("logsPanel"), + secretsPanel: document.getElementById("secretsPanel"), + s3Panel: document.getElementById("s3Panel"), + refreshButton: document.getElementById("refreshButton"), + autoToggle: document.getElementById("autoToggle"), + intervalSelect: document.getElementById("intervalSelect"), + searchInput: document.getElementById("searchInput"), + clearSearchButton: document.getElementById("clearSearchButton"), + expandAllButton: document.getElementById("expandAllButton"), + collapseAllButton: document.getElementById("collapseAllButton"), + scrollToTopButton: document.getElementById("scrollToTopButton"), + statusChip: document.getElementById("statusChip"), + totalStat: document.getElementById("totalStat"), + visibleStat: document.getElementById("visibleStat"), + newStat: document.getElementById("newStat"), + newestStat: document.getElementById("newestStat"), + updatedStat: document.getElementById("updatedStat"), + fetchStat: document.getElementById("fetchStat"), + fetchDetail: document.getElementById("fetchDetail"), + banner: document.getElementById("banner"), + empty: document.getElementById("empty"), + list: document.getElementById("list"), + emailsContentPane: document.getElementById("emailsContentPane"), + logsRefreshButton: document.getElementById("logsRefreshButton"), + logsAutoToggle: document.getElementById("logsAutoToggle"), + logsIntervalSelect: document.getElementById("logsIntervalSelect"), + logsGroupSelect: document.getElementById("logsGroupSelect"), + logsStreamSelect: document.getElementById("logsStreamSelect"), + logsWindowSelect: document.getElementById("logsWindowSelect"), + logsLimitSelect: document.getElementById("logsLimitSelect"), + logsSearchInput: document.getElementById("logsSearchInput"), + logsClearSearchButton: document.getElementById("logsClearSearchButton"), + logsWrapToggle: document.getElementById("logsWrapToggle"), + logsTailToggle: document.getElementById("logsTailToggle"), + logsExpandAllButton: document.getElementById("logsExpandAllButton"), + logsCollapseAllButton: document.getElementById("logsCollapseAllButton"), + logsScrollToTopButton: document.getElementById("logsScrollToTopButton"), + logsStatusChip: document.getElementById("logsStatusChip"), + logsTotalStat: document.getElementById("logsTotalStat"), + logsVisibleStat: document.getElementById("logsVisibleStat"), + logsStreamsStat: document.getElementById("logsStreamsStat"), + logsNewestStat: document.getElementById("logsNewestStat"), + logsUpdatedStat: document.getElementById("logsUpdatedStat"), + logsFetchStat: document.getElementById("logsFetchStat"), + logsFetchDetail: document.getElementById("logsFetchDetail"), + logsBanner: document.getElementById("logsBanner"), + logsEmpty: document.getElementById("logsEmpty"), + logsList: document.getElementById("logsList"), + logsContentPane: document.getElementById("logsContentPane"), + secretsRefreshButton: document.getElementById("secretsRefreshButton"), + secretsAutoToggle: document.getElementById("secretsAutoToggle"), + secretsIntervalSelect: document.getElementById("secretsIntervalSelect"), + secretsSearchInput: document.getElementById("secretsSearchInput"), + secretsClearSearchButton: document.getElementById("secretsClearSearchButton"), + secretsExpandAllButton: document.getElementById("secretsExpandAllButton"), + secretsCollapseAllButton: document.getElementById("secretsCollapseAllButton"), + secretsScrollToTopButton: document.getElementById("secretsScrollToTopButton"), + secretsStatusChip: document.getElementById("secretsStatusChip"), + secretsTotalStat: document.getElementById("secretsTotalStat"), + secretsVisibleStat: document.getElementById("secretsVisibleStat"), + secretsLoadedStat: document.getElementById("secretsLoadedStat"), + secretsNewestStat: document.getElementById("secretsNewestStat"), + secretsUpdatedStat: document.getElementById("secretsUpdatedStat"), + secretsFetchStat: document.getElementById("secretsFetchStat"), + secretsFetchDetail: document.getElementById("secretsFetchDetail"), + secretsBanner: document.getElementById("secretsBanner"), + secretsEmpty: document.getElementById("secretsEmpty"), + secretsList: document.getElementById("secretsList"), + secretsContentPane: document.getElementById("secretsContentPane"), + s3RefreshButton: document.getElementById("s3RefreshButton"), + s3AutoToggle: document.getElementById("s3AutoToggle"), + s3IntervalSelect: document.getElementById("s3IntervalSelect"), + s3BucketSelect: document.getElementById("s3BucketSelect"), + s3PrefixInput: document.getElementById("s3PrefixInput"), + s3ApplyPrefixButton: document.getElementById("s3ApplyPrefixButton"), + s3SearchInput: document.getElementById("s3SearchInput"), + s3ClearSearchButton: document.getElementById("s3ClearSearchButton"), + s3ExpandAllButton: document.getElementById("s3ExpandAllButton"), + s3CollapseAllButton: document.getElementById("s3CollapseAllButton"), + s3ScrollToTopButton: document.getElementById("s3ScrollToTopButton"), + s3StatusChip: document.getElementById("s3StatusChip"), + s3TotalStat: document.getElementById("s3TotalStat"), + s3VisibleStat: document.getElementById("s3VisibleStat"), + s3BucketsStat: document.getElementById("s3BucketsStat"), + s3NewestStat: document.getElementById("s3NewestStat"), + s3UpdatedStat: document.getElementById("s3UpdatedStat"), + s3FetchStat: document.getElementById("s3FetchStat"), + s3FetchDetail: document.getElementById("s3FetchDetail"), + s3Banner: document.getElementById("s3Banner"), + s3Empty: document.getElementById("s3Empty"), + s3List: document.getElementById("s3List"), + s3ContentPane: document.getElementById("s3ContentPane") + }; + + el.autoToggle.checked = state.auto; + el.intervalSelect.value = String(state.interval); + el.searchInput.value = state.search; + el.logsAutoToggle.checked = logsState.auto; + el.logsIntervalSelect.value = String(logsState.interval); + el.logsWindowSelect.value = String(logsState.windowMs); + el.logsLimitSelect.value = String(logsState.limit); + el.logsSearchInput.value = logsState.search; + el.logsWrapToggle.checked = logsState.wrapLines; + el.logsTailToggle.checked = logsState.tailNewest; + el.secretsAutoToggle.checked = secretsState.auto; + el.secretsIntervalSelect.value = String(secretsState.interval); + el.secretsSearchInput.value = secretsState.search; + el.s3AutoToggle.checked = s3State.auto; + el.s3IntervalSelect.value = String(s3State.interval); + el.s3PrefixInput.value = s3State.prefix; + el.s3SearchInput.value = s3State.search; + applyTheme(appState.theme); + persistPanel(); + persistEmailPreferences(); + persistLogPreferences(); + persistSecretPreferences(); + persistS3Preferences(); + wire(); + renderWorkspace(); + renderAll(); + renderLogsAll(); + renderSecretsAll(); + renderS3All(); + renderHealthStrip(); + if (appState.panel === "logs") { + ensureLogsReady(); + } else if (appState.panel === "secrets") { + ensureSecretsReady(); + } else if (appState.panel === "s3") { + ensureS3Ready(); + } else { + refreshMessages("initial"); + } + refreshHealthSummary("initial"); + window.setInterval(() => { + if (!document.hidden) { + refreshHealthSummary("auto"); + } + }, HEALTH_REFRESH_MS); + window.setInterval(() => { + renderLiveClock(); + renderLogsLiveClock(); + renderSecretsLiveClock(); + renderS3LiveClock(); + renderHealthStrip(); + }, 1000); + + function wire() { + el.themeToggle.addEventListener("click", () => { + applyTheme(appState.theme === "dark" ? "light" : "dark"); + }); + + el.resetStateButton.addEventListener("click", () => { + resetSavedState(); + }); + + el.healthRefreshButton.addEventListener("click", () => { + refreshHealthSummary("manual"); + }); + + el.healthStrip.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-health-panel]"); + + if (!button) { + return; + } + + await setPanel(button.dataset.healthPanel); + }); + + el.refreshButton.addEventListener("click", () => refreshMessages("manual")); + + el.autoToggle.addEventListener("change", () => { + state.auto = el.autoToggle.checked; + persistEmailPreferences(); + scheduleRefresh(); + renderStatus(); + }); + + el.intervalSelect.addEventListener("change", () => { + state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs; + persistEmailPreferences(); + scheduleRefresh(); + renderStatus(); + }); + + el.searchInput.addEventListener("input", (event) => { + state.search = event.target.value; + applyFilter(); + }); + + el.clearSearchButton.addEventListener("click", () => { + state.search = ""; + el.searchInput.value = ""; + applyFilter(); + }); + + el.expandAllButton.addEventListener("click", () => { + state.filtered.forEach((message) => state.openIds.add(message.id)); + syncCardExpansion(); + }); + + el.collapseAllButton.addEventListener("click", () => { + state.openIds.clear(); + syncCardExpansion(); + }); + + el.scrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.emailsContentPane); + }); + + el.emailsContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + }); + + el.list.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const message = getMessage(id); + + if (!message) { + return; + } + + if (button.dataset.action === "view") { + state.views[id] = button.dataset.view; + renderList(); + return; + } + + if (button.dataset.action === "load-raw") { + await loadRaw(id); + renderList(); + return; + } + + if (button.dataset.action === "copy-raw") { + const raw = await loadRaw(id); + + if (raw) { + await copyText(raw); + setStatus("Raw message copied to the clipboard.", "ok"); + } + } + }); + + el.logsRefreshButton.addEventListener("click", () => refreshLogs("manual")); + + el.logsAutoToggle.addEventListener("change", () => { + logsState.auto = el.logsAutoToggle.checked; + persistLogPreferences(); + scheduleLogsRefresh(); + renderLogsStatus(); + }); + + el.logsIntervalSelect.addEventListener("change", () => { + logsState.interval = Number(el.logsIntervalSelect.value) || config.defaultRefreshMs; + persistLogPreferences(); + scheduleLogsRefresh(); + renderLogsStatus(); + }); + + el.logsWindowSelect.addEventListener("change", () => { + logsState.windowMs = Number(el.logsWindowSelect.value) || config.defaultLogWindowMs; + persistLogPreferences(); + refreshLogs("window"); + }); + + el.logsLimitSelect.addEventListener("change", () => { + logsState.limit = Number(el.logsLimitSelect.value) || config.defaultLogLimit; + persistLogPreferences(); + refreshLogs("limit"); + }); + + el.logsSearchInput.addEventListener("input", (event) => { + logsState.search = event.target.value; + applyLogsFilter(); + }); + + el.logsClearSearchButton.addEventListener("click", () => { + logsState.search = ""; + el.logsSearchInput.value = ""; + applyLogsFilter(); + }); + + el.logsWrapToggle.addEventListener("change", () => { + logsState.wrapLines = el.logsWrapToggle.checked; + persistLogPreferences(); + renderLogsList(); + }); + + el.logsTailToggle.addEventListener("change", () => { + logsState.tailNewest = el.logsTailToggle.checked; + persistLogPreferences(); + }); + + el.logsExpandAllButton.addEventListener("click", () => { + logsState.filtered.forEach((event) => logsState.openIds.add(event.id)); + renderLogsList(); + }); + + el.logsCollapseAllButton.addEventListener("click", () => { + logsState.openIds.clear(); + renderLogsList(); + }); + + el.logsScrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.logsContentPane); + }); + + el.logsContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + }); + + el.logsGroupSelect.addEventListener("change", async () => { + logsState.group = el.logsGroupSelect.value; + logsState.stream = ""; + persistLogPreferences(); + await refreshLogStreams(); + await refreshLogs("group"); + }); + + el.logsStreamSelect.addEventListener("change", () => { + logsState.stream = el.logsStreamSelect.value; + persistLogPreferences(); + refreshLogs("stream"); + }); + + el.logsList.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-log-action]"); + + if (!button) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const logEvent = getLogEvent(button.dataset.id); + + if (!logEvent) { + return; + } + + if (button.dataset.logAction === "copy") { + await copyText(formatLogMessage(logEvent.message)); + setLogsStatus("Log payload copied to the clipboard.", "ok"); + } + }); + + el.secretsRefreshButton.addEventListener("click", () => refreshSecrets("manual")); + + el.secretsAutoToggle.addEventListener("change", () => { + secretsState.auto = el.secretsAutoToggle.checked; + persistSecretPreferences(); + scheduleSecretsRefresh(); + renderSecretsStatus(); + }); + + el.secretsIntervalSelect.addEventListener("change", () => { + secretsState.interval = Number(el.secretsIntervalSelect.value) || config.defaultRefreshMs; + persistSecretPreferences(); + scheduleSecretsRefresh(); + renderSecretsStatus(); + }); + + el.secretsSearchInput.addEventListener("input", (event) => { + secretsState.search = event.target.value; + applySecretsFilter(); + }); + + el.secretsClearSearchButton.addEventListener("click", () => { + secretsState.search = ""; + el.secretsSearchInput.value = ""; + applySecretsFilter(); + }); + + el.secretsExpandAllButton.addEventListener("click", () => { + secretsState.filtered.forEach((secret) => secretsState.openIds.add(secret.id)); + syncSecretExpansion(); + }); + + el.secretsCollapseAllButton.addEventListener("click", () => { + secretsState.openIds.clear(); + syncSecretExpansion(); + }); + + el.secretsScrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.secretsContentPane); + }); + + el.secretsContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + }); + + el.secretsList.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-secret-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const secret = getSecret(id); + + if (!secret) { + return; + } + + if (button.dataset.secretAction === "load-value") { + await ensureSecretValue(id, { force: false }); + return; + } + + if (button.dataset.secretAction === "reload-value") { + await ensureSecretValue(id, { force: true }); + return; + } + + if (button.dataset.secretAction === "copy-value") { + const entry = await ensureSecretValue(id, { force: false }); + + if (entry?.status === "loaded") { + await copyText(entry.copyValue); + setSecretsStatus("Secret value copied to the clipboard.", "ok"); + } + + return; + } + + if (button.dataset.secretAction === "toggle-reveal") { + toggleSecretReveal(id); + return; + } + + if (button.dataset.secretAction === "copy-name") { + await copyText(secret.name || ""); + setSecretsStatus("Secret name copied to the clipboard.", "ok"); + return; + } + + if (button.dataset.secretAction === "copy-arn") { + await copyText(secret.arn || ""); + setSecretsStatus("Secret ARN copied to the clipboard.", "ok"); + return; + } + + if (button.dataset.secretAction === "copy-env") { + const entry = await ensureSecretValue(id, { force: false }); + + if (entry?.status === "loaded") { + await copyText(`${secret.name}=${entry.copyValue}`); + setSecretsStatus("Secret env line copied to the clipboard.", "ok"); + } + } + }); + + el.s3RefreshButton.addEventListener("click", async () => { + await refreshS3Buckets(); + await refreshS3("manual"); + }); + + el.s3AutoToggle.addEventListener("change", () => { + s3State.auto = el.s3AutoToggle.checked; + persistS3Preferences(); + scheduleS3Refresh(); + renderS3Status(); + }); + + el.s3IntervalSelect.addEventListener("change", () => { + s3State.interval = Number(el.s3IntervalSelect.value) || config.defaultRefreshMs; + persistS3Preferences(); + scheduleS3Refresh(); + renderS3Status(); + }); + + el.s3BucketSelect.addEventListener("change", () => { + s3State.bucket = el.s3BucketSelect.value; + persistS3Preferences(); + refreshS3("bucket"); + }); + + el.s3PrefixInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + s3State.prefix = el.s3PrefixInput.value.trim(); + persistS3Preferences(); + refreshS3("prefix"); + } + }); + + el.s3ApplyPrefixButton.addEventListener("click", () => { + s3State.prefix = el.s3PrefixInput.value.trim(); + persistS3Preferences(); + refreshS3("prefix"); + }); + + el.s3SearchInput.addEventListener("input", (event) => { + s3State.search = event.target.value; + applyS3Filter(); + }); + + el.s3ClearSearchButton.addEventListener("click", () => { + s3State.search = ""; + el.s3SearchInput.value = ""; + applyS3Filter(); + }); + + el.s3ExpandAllButton.addEventListener("click", () => { + s3State.filtered.forEach((object) => s3State.openIds.add(object.id)); + syncS3Expansion(); + }); + + el.s3CollapseAllButton.addEventListener("click", () => { + s3State.openIds.clear(); + syncS3Expansion(); + }); + + el.s3ScrollToTopButton.addEventListener("click", () => { + scrollPaneToTop(el.s3ContentPane); + }); + + el.s3ContentPane.addEventListener("scroll", () => { + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + }); + + el.s3List.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-s3-action]"); + + if (!button) { + return; + } + + const id = button.dataset.id; + const object = getS3Object(id); + + if (!object) { + return; + } + + if (button.dataset.s3Action === "load-preview") { + await ensureS3Preview(id, { force: false }); + return; + } + + if (button.dataset.s3Action === "reload-preview") { + await ensureS3Preview(id, { force: true }); + return; + } + + if (button.dataset.s3Action === "copy-key") { + await copyText(object.key); + setS3Status("Object key copied to the clipboard.", "ok"); + return; + } + + if (button.dataset.s3Action === "copy-uri") { + await copyText(`s3://${object.bucket}/${object.key}`); + setS3Status("S3 URI copied to the clipboard.", "ok"); + } + }); + + document.addEventListener("visibilitychange", () => { + window.clearTimeout(state.timer); + window.clearTimeout(logsState.timer); + window.clearTimeout(secretsState.timer); + window.clearTimeout(s3State.timer); + + if (document.hidden) { + renderStatus(); + renderLogsStatus(); + renderSecretsStatus(); + renderS3Status(); + return; + } + + refreshHealthSummary("visibility"); + + if (appState.panel === "s3") { + if (s3State.auto) { + refreshS3("visibility"); + } else { + renderS3Status(); + } + return; + } + + if (appState.panel === "secrets") { + if (secretsState.auto) { + refreshSecrets("visibility"); + } else { + renderSecretsStatus(); + } + return; + } + + if (appState.panel === "logs") { + if (logsState.auto) { + refreshLogs("visibility"); + } else { + renderLogsStatus(); + } + return; + } + + if (state.auto) { + refreshMessages("visibility"); + } else { + renderStatus(); + } + }); + + window.addEventListener("keydown", (event) => { + const isField = + event.target instanceof HTMLElement && + (event.target.matches("input,textarea,select") || event.target.isContentEditable); + + if (!isField && event.key.toLowerCase() === "r") { + event.preventDefault(); + + if (appState.panel === "logs") { + refreshLogs("keyboard"); + return; + } + + if (appState.panel === "secrets") { + refreshSecrets("keyboard"); + return; + } + + if (appState.panel === "s3") { + refreshS3("keyboard"); + return; + } + + refreshMessages("keyboard"); + } + }); + + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + } + + async function setPanel(panel) { + if (!panel || panel === appState.panel) { + if (panel === "logs") { + await ensureLogsReady(); + } else if (panel === "secrets") { + await ensureSecretsReady(); + } else if (panel === "s3") { + await ensureS3Ready(); + } + + renderWorkspace(); + return; + } + + appState.panel = panel; + persistPanel(); + renderWorkspace(); + + if (panel === "logs") { + await ensureLogsReady(); + } else if (panel === "secrets") { + await ensureSecretsReady(); + } else if (panel === "s3") { + await ensureS3Ready(); + } else if (!state.updatedAt && !state.loading) { + await refreshMessages("panel"); + } + + scheduleRefresh(); + scheduleLogsRefresh(); + scheduleSecretsRefresh(); + scheduleS3Refresh(); + renderStatus(); + renderLogsStatus(); + renderSecretsStatus(); + renderS3Status(); + } + + function renderWorkspace() { + el.emailsPanel.hidden = appState.panel !== "emails"; + el.logsPanel.hidden = appState.panel !== "logs"; + el.secretsPanel.hidden = appState.panel !== "secrets"; + el.s3Panel.hidden = appState.panel !== "s3"; + renderHealthStrip(); + } + + async function ensureLogsReady() { + if (appState.logsReady) { + return; + } + + await refreshLogGroups(); + appState.logsReady = !logsState.error; + + if (logsState.group) { + await refreshLogs("initial"); + } + } + + async function ensureSecretsReady() { + if (appState.secretsReady) { + return; + } + + await refreshSecrets("initial"); + appState.secretsReady = !secretsState.error; + } + + async function ensureS3Ready() { + if (appState.s3Ready) { + return; + } + + await refreshS3Buckets(); + appState.s3Ready = !s3State.error; + + if (s3State.bucket) { + await refreshS3("initial"); + } + } + + async function refreshMessages(source) { + if (state.loading) { + return; + } + + let shouldRenderList = false; + + state.loading = true; + state.source = source; + state.error = ""; + renderStatus(); + renderFetch(); + + try { + const response = await fetch("/api/messages", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const nextIds = new Set(messages.map((message) => message.id)); + const nextSignature = computeListSignature(messages); + + shouldRenderList = nextSignature !== state.listSignature; + state.newIds = + state.updatedAt && shouldRenderList + ? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id)) + : state.newIds; + state.knownIds = nextIds; + state.messages = messages; + state.duration = payload.fetchDurationMs || 0; + state.parseErrors = payload.parseErrors || 0; + state.newest = payload.latestMessageTimestamp || ""; + state.updatedAt = Date.now(); + state.listSignature = nextSignature; + + pruneState(); + applyFilter(shouldRenderList); + setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + state.error = error.message || "Unknown refresh error"; + setStatus(`Refresh failed: ${state.error}`, "bad"); + } finally { + state.loading = false; + scheduleRefresh(); + renderAll({ renderList: shouldRenderList }); + } + } + + async function refreshLogGroups() { + try { + const response = await fetch("/api/logs/groups", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + logsState.groups = Array.isArray(payload.groups) ? payload.groups : []; + + const availableGroups = logsState.groups.map((group) => group.name).filter(Boolean); + + if (!availableGroups.includes(logsState.group)) { + logsState.group = availableGroups.includes(config.defaultLogGroup) + ? config.defaultLogGroup + : availableGroups[0] || ""; + } + + logsState.error = ""; + await refreshLogStreams(); + } catch (error) { + logsState.error = error.message || "Unknown log group refresh error"; + } finally { + persistLogPreferences(); + renderLogsAll(); + } + } + + async function refreshLogStreams() { + if (!logsState.group) { + logsState.streams = []; + logsState.stream = ""; + renderLogsAll(); + return; + } + + try { + const response = await fetch(`/api/logs/streams?group=${encodeURIComponent(logsState.group)}`, { + cache: "no-store" + }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + logsState.streams = Array.isArray(payload.streams) ? payload.streams : []; + + if (!logsState.streams.some((stream) => stream.name === logsState.stream)) { + logsState.stream = ""; + } + + logsState.error = ""; + } catch (error) { + logsState.streams = []; + logsState.stream = ""; + logsState.error = error.message || "Unknown log stream refresh error"; + } finally { + persistLogPreferences(); + renderLogsAll(); + } + } + + async function refreshLogs(source) { + if (logsState.loading) { + return; + } + + if (!appState.logsReady) { + await ensureLogsReady(); + return; + } + + if (!logsState.group) { + logsState.error = logsState.groups.length ? "Choose a log group to load events." : "No log groups found."; + renderLogsAll(); + return; + } + + let shouldRenderList = false; + + logsState.loading = true; + logsState.source = source; + logsState.error = ""; + renderLogsStatus(); + renderLogsFetch(); + + try { + const params = new URLSearchParams({ + group: logsState.group, + windowMs: String(logsState.windowMs), + limit: String(logsState.limit) + }); + + if (logsState.stream) { + params.set("stream", logsState.stream); + } + + const response = await fetch(`/api/logs/events?${params.toString()}`, { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const events = Array.isArray(payload.events) ? payload.events : []; + const nextSignature = computeLogListSignature(events); + shouldRenderList = nextSignature !== logsState.listSignature; + logsState.events = events; + logsState.duration = payload.fetchDurationMs || 0; + logsState.newest = payload.latestTimestamp || 0; + logsState.updatedAt = Date.now(); + logsState.searchedLogStreams = payload.searchedLogStreams || (logsState.stream ? 1 : logsState.streams.length); + logsState.listSignature = nextSignature; + + pruneLogsState(); + applyLogsFilter(shouldRenderList); + setLogsStatus(`Updated ${logsState.events.length} log event${logsState.events.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + logsState.error = error.message || "Unknown log refresh error"; + setLogsStatus(`Refresh failed: ${logsState.error}`, "bad"); + } finally { + logsState.loading = false; + scheduleLogsRefresh(); + renderLogsAll({ renderList: shouldRenderList }); + + if (shouldRenderList && logsState.tailNewest) { + scrollPaneToTop(el.logsContentPane); + } + } + } + + async function refreshSecrets(source) { + if (secretsState.loading) { + return; + } + + let shouldRenderList = false; + + secretsState.loading = true; + secretsState.source = source; + secretsState.error = ""; + renderSecretsStatus(); + renderSecretsFetch(); + + try { + const response = await fetch("/api/secrets", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const items = Array.isArray(payload.secrets) ? payload.secrets : []; + const nextSignature = computeSecretsListSignature(items); + shouldRenderList = nextSignature !== secretsState.listSignature; + secretsState.items = items; + secretsState.duration = payload.fetchDurationMs || 0; + secretsState.newest = payload.latestTimestamp || ""; + secretsState.updatedAt = Date.now(); + secretsState.listSignature = nextSignature; + + pruneSecretsState(); + applySecretsFilter(shouldRenderList); + appState.secretsReady = true; + setSecretsStatus(`Updated ${items.length} secret${items.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + secretsState.error = error.message || "Unknown secrets refresh error"; + appState.secretsReady = false; + setSecretsStatus(`Refresh failed: ${secretsState.error}`, "bad"); + } finally { + secretsState.loading = false; + scheduleSecretsRefresh(); + renderSecretsAll({ renderList: shouldRenderList }); + } + } + + async function refreshHealthSummary(source) { + if (healthState.loading && source === "auto") { + return; + } + + healthState.loading = true; + healthState.source = source; + renderHealthStrip(); + + try { + const response = await fetch("/api/service-health", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + healthState.services = payload.services || {}; + healthState.updatedAt = Date.now(); + healthState.error = ""; + } catch (error) { + healthState.error = error.message || "Unknown health refresh error"; + } finally { + healthState.loading = false; + renderHealthStrip(); + } + } + + async function refreshS3Buckets() { + try { + const response = await fetch("/api/s3/buckets", { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + s3State.buckets = Array.isArray(payload.buckets) ? payload.buckets : []; + + const availableBuckets = s3State.buckets.map((bucket) => bucket.name).filter(Boolean); + + if (!availableBuckets.includes(s3State.bucket)) { + s3State.bucket = availableBuckets.includes(config.defaultS3Bucket) + ? config.defaultS3Bucket + : availableBuckets[0] || ""; + } + + s3State.error = ""; + } catch (error) { + s3State.buckets = []; + s3State.bucket = ""; + s3State.error = error.message || "Unknown S3 bucket refresh error"; + } finally { + appState.s3Ready = !s3State.error; + persistS3Preferences(); + renderS3All(); + } + } + + async function refreshS3(source) { + if (s3State.loading) { + return; + } + + if (!appState.s3Ready) { + await ensureS3Ready(); + return; + } + + if (!s3State.bucket) { + s3State.error = s3State.buckets.length ? "Choose a bucket to load objects." : "No S3 buckets found."; + renderS3All(); + return; + } + + let shouldRenderList = false; + + s3State.loading = true; + s3State.source = source; + s3State.error = ""; + renderS3Status(); + renderS3Fetch(); + + try { + const params = new URLSearchParams({ + bucket: s3State.bucket + }); + + if (s3State.prefix) { + params.set("prefix", s3State.prefix); + } + + const response = await fetch(`/api/s3/objects?${params.toString()}`, { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const objects = Array.isArray(payload.objects) ? payload.objects : []; + const nextSignature = computeS3ListSignature(objects); + shouldRenderList = nextSignature !== s3State.listSignature; + s3State.objects = objects; + s3State.duration = payload.fetchDurationMs || 0; + s3State.newest = payload.latestTimestamp || ""; + s3State.updatedAt = Date.now(); + s3State.listSignature = nextSignature; + + pruneS3State(); + applyS3Filter(shouldRenderList); + appState.s3Ready = true; + setS3Status(`Updated ${objects.length} object${objects.length === 1 ? "" : "s"}.`, "ok"); + } catch (error) { + s3State.error = error.message || "Unknown S3 refresh error"; + appState.s3Ready = false; + setS3Status(`Refresh failed: ${s3State.error}`, "bad"); + } finally { + s3State.loading = false; + scheduleS3Refresh(); + renderS3All({ renderList: shouldRenderList }); + } + } + + function applyLogsFilter(shouldRenderList = true) { + const search = logsState.search.trim().toLowerCase(); + logsState.filtered = !search + ? [...logsState.events] + : logsState.events.filter((event) => logHaystack(event).includes(search)); + persistLogPreferences(); + renderLogsAll({ renderList: shouldRenderList }); + } + + function applySecretsFilter(shouldRenderList = true) { + const search = secretsState.search.trim().toLowerCase(); + secretsState.filtered = !search + ? [...secretsState.items] + : secretsState.items.filter((secret) => secretHaystack(secret).includes(search)); + persistSecretPreferences(); + renderSecretsAll({ renderList: shouldRenderList }); + } + + function applyS3Filter(shouldRenderList = true) { + const search = s3State.search.trim().toLowerCase(); + s3State.filtered = !search + ? [...s3State.objects] + : s3State.objects.filter((object) => s3Haystack(object).includes(search)); + persistS3Preferences(); + renderS3All({ renderList: shouldRenderList }); + } + + function pruneState() { + const ids = new Set(state.messages.map((message) => message.id)); + state.openIds = new Set([...state.openIds].filter((id) => ids.has(id))); + state.newIds = new Set([...state.newIds].filter((id) => ids.has(id))); + + Object.keys(state.views).forEach((id) => { + if (!ids.has(id)) { + delete state.views[id]; + } + }); + + Object.keys(state.raw).forEach((id) => { + if (!ids.has(id)) { + delete state.raw[id]; + } + }); + } + + function pruneLogsState() { + const ids = new Set(logsState.events.map((event) => event.id)); + logsState.openIds = new Set([...logsState.openIds].filter((id) => ids.has(id))); + } + + function pruneSecretsState() { + const ids = new Set(secretsState.items.map((secret) => secret.id)); + secretsState.openIds = new Set([...secretsState.openIds].filter((id) => ids.has(id))); + + Object.keys(secretsState.values).forEach((id) => { + if (!ids.has(id)) { + delete secretsState.values[id]; + } + }); + + secretsState.revealedIds = new Set([...secretsState.revealedIds].filter((id) => ids.has(id))); + } + + function pruneS3State() { + const ids = new Set(s3State.objects.map((object) => object.id)); + s3State.openIds = new Set([...s3State.openIds].filter((id) => ids.has(id))); + + Object.keys(s3State.previews).forEach((id) => { + if (!ids.has(id)) { + delete s3State.previews[id]; + } + }); + } + + function applyFilter(shouldRenderList = true) { + const search = state.search.trim().toLowerCase(); + state.filtered = !search + ? [...state.messages] + : state.messages.filter((message) => haystack(message).includes(search)); + persistEmailPreferences(); + renderAll({ renderList: shouldRenderList }); + } + + function computeListSignature(messages) { + return messages + .map((message) => + [ + message.id, + message.timestampMs || 0, + message.rawSizeBytes || 0, + message.attachmentCount || 0, + message.hasHtml ? 1 : 0, + message.preview || "", + message.parseError || "" + ].join("::") + ) + .join("|"); + } + + function computeLogListSignature(events) { + return events + .map((event) => + [event.id, event.timestamp || 0, event.ingestionTime || 0, event.logStreamName || "", event.message || ""].join( + "::" + ) + ) + .join("|"); + } + + function computeSecretsListSignature(items) { + return items + .map((secret) => + [ + secret.id, + secret.name || "", + secret.arn || "", + secret.description || "", + secret.lastChangedDate || "", + secret.createdDate || "", + secret.rotationEnabled ? 1 : 0, + secret.owningService || "", + secret.primaryRegion || "", + secret.versionCount || 0, + secret.tagCount || 0 + ].join("::") + ) + .join("|"); + } + + function computeS3ListSignature(objects) { + return objects + .map((object) => + [object.id, object.key || "", object.size || 0, object.lastModified || "", object.etag || ""].join("::") + ) + .join("|"); + } + + function haystack(message) { + return [ + message.subject, + message.from, + message.to, + message.replyTo, + message.preview, + message.textContent, + message.region, + ...(message.attachments || []).flatMap((attachment) => [attachment.filename, attachment.contentType]) + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + } + + function logHaystack(event) { + return [event.logStreamName, event.message, event.preview].filter(Boolean).join(" ").toLowerCase(); + } + + function secretHaystack(secret) { + return [ + secret.name, + secret.arn, + secret.description, + secret.primaryRegion, + secret.owningService, + ...(secret.tags || []).flatMap((tag) => [tag.key, tag.value]) + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + } + + function s3Haystack(object) { + return [object.bucket, object.key, object.storageClass, object.etag].filter(Boolean).join(" ").toLowerCase(); + } + + function scheduleRefresh() { + window.clearTimeout(state.timer); + + if (appState.panel !== "emails" || !state.auto || document.hidden || state.loading) { + return; + } + + state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval); + } + + function scheduleLogsRefresh() { + window.clearTimeout(logsState.timer); + + if (appState.panel !== "logs" || !logsState.auto || document.hidden || logsState.loading) { + return; + } + + logsState.timer = window.setTimeout(() => refreshLogs("auto"), logsState.interval); + } + + function scheduleSecretsRefresh() { + window.clearTimeout(secretsState.timer); + + if (appState.panel !== "secrets" || !secretsState.auto || document.hidden || secretsState.loading) { + return; + } + + secretsState.timer = window.setTimeout(() => refreshSecrets("auto"), secretsState.interval); + } + + function scheduleS3Refresh() { + window.clearTimeout(s3State.timer); + + if (appState.panel !== "s3" || !s3State.auto || document.hidden || s3State.loading) { + return; + } + + s3State.timer = window.setTimeout(() => refreshS3("auto"), s3State.interval); + } + + function renderAll(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderStats(); + renderFetch(); + renderStatus(); + if (shouldRenderList) { + renderList(); + } + renderLiveClock(); + } + + function renderLogsAll(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderLogsFilters(); + renderLogsStats(); + renderLogsFetch(); + renderLogsStatus(); + if (shouldRenderList) { + renderLogsList(); + } + renderLogsLiveClock(); + } + + function renderSecretsAll(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderSecretsStats(); + renderSecretsFetch(); + renderSecretsStatus(); + if (shouldRenderList) { + renderSecretsList(); + } + renderSecretsLiveClock(); + } + + function renderS3All(options = {}) { + const { renderList: shouldRenderList = true } = options; + renderS3Filters(); + renderS3Stats(); + renderS3Fetch(); + renderS3Status(); + if (shouldRenderList) { + renderS3List(); + } + renderS3LiveClock(); + } + + function renderHealthStrip() { + const serviceOrder = [ + { key: "emails", panel: "emails", icon: "โœ‰๏ธ", label: "SES Emails", shortLabel: "SES" }, + { key: "logs", panel: "logs", icon: "๐Ÿ“œ", label: "CloudWatch Logs", shortLabel: "Logs" }, + { key: "secrets", panel: "secrets", icon: "๐Ÿ”", label: "Secrets Manager", shortLabel: "Secrets" }, + { key: "s3", panel: "s3", icon: "๐Ÿชฃ", label: "S3 Explorer", shortLabel: "S3" } + ]; + + el.healthStrip.innerHTML = serviceOrder + .map((service) => { + const entry = healthState.services?.[service.key]; + const toneClass = healthState.loading && !entry ? "warn" : entry?.ok ? "ok" : entry ? "bad" : ""; + const activeClass = service.panel === appState.panel ? "active" : ""; + const className = ["healthBadge", toneClass, activeClass].filter(Boolean).join(" "); + const summary = entry?.summary || (healthState.loading ? "Checking..." : "Waiting"); + const detail = entry?.detail || (healthState.error ? healthState.error : "Not checked yet"); + const updatedMeta = healthState.updatedAt + ? `Updated ${formatRelative(healthState.updatedAt)} via ${healthState.source}` + : ""; + const titleParts = [`${service.label}: ${detail}`]; + + if (updatedMeta) { + titleParts.push(updatedMeta); + } + + return ``; + }) + .join(""); + } + + function renderStats() { + el.totalStat.textContent = String(state.messages.length); + el.visibleStat.textContent = `${state.filtered.length} visible${state.search ? " after search" : ""}`; + el.newStat.textContent = String(state.newIds.size); + el.newestStat.textContent = state.newest ? formatDateTime(state.newest) : "No messages"; + } + + function renderLogsFilters() { + const groups = logsState.groups.length + ? logsState.groups.map((group) => ``) + : ['']; + const streams = [ + '', + ...logsState.streams.map( + (stream) => `` + ) + ]; + + el.logsGroupSelect.innerHTML = groups.join(""); + el.logsStreamSelect.innerHTML = streams.join(""); + + if (logsState.group) { + el.logsGroupSelect.value = logsState.group; + } + + el.logsStreamSelect.value = logsState.stream; + el.logsWrapToggle.checked = logsState.wrapLines; + el.logsTailToggle.checked = logsState.tailNewest; + } + + function renderS3Filters() { + const bucketOptions = s3State.buckets.length + ? s3State.buckets.map( + (bucket) => `` + ) + : ['']; + + el.s3BucketSelect.innerHTML = bucketOptions.join(""); + + if (s3State.bucket) { + el.s3BucketSelect.value = s3State.bucket; + } + + el.s3PrefixInput.value = s3State.prefix; + el.s3SearchInput.value = s3State.search; + } + + function renderLogsStats() { + el.logsTotalStat.textContent = String(logsState.events.length); + el.logsVisibleStat.textContent = `${logsState.filtered.length} visible${logsState.search ? " after search" : ""}`; + el.logsStreamsStat.textContent = String(logsState.streams.length || logsState.searchedLogStreams || 0); + el.logsNewestStat.textContent = logsState.newest ? formatDateTime(logsState.newest) : "No events"; + } + + function renderSecretsStats() { + el.secretsTotalStat.textContent = String(secretsState.items.length); + el.secretsVisibleStat.textContent = `${secretsState.filtered.length} visible${secretsState.search ? " after search" : ""}`; + el.secretsLoadedStat.textContent = String( + Object.values(secretsState.values).filter((entry) => entry?.status === "loaded").length + ); + el.secretsNewestStat.textContent = secretsState.newest ? formatDateTime(secretsState.newest) : "No secrets"; + } + + function renderS3Stats() { + el.s3TotalStat.textContent = String(s3State.objects.length); + el.s3VisibleStat.textContent = `${s3State.filtered.length} visible${s3State.search ? " after search" : ""}`; + el.s3BucketsStat.textContent = String(s3State.buckets.length); + el.s3NewestStat.textContent = s3State.newest ? formatDateTime(s3State.newest) : "No objects"; + } + + function renderFetch() { + if (state.loading) { + el.fetchStat.textContent = "Refreshing..."; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + if (state.error) { + el.fetchStat.textContent = "Needs attention"; + el.fetchDetail.textContent = state.error; + return; + } + + if (!state.updatedAt) { + el.fetchStat.textContent = "Idle"; + el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`; + return; + } + + el.fetchStat.textContent = `${state.duration}ms`; + el.fetchDetail.textContent = `${state.parseErrors} parse error${state.parseErrors === 1 ? "" : "s"}. Endpoint: ${config.endpoint}`; + } + + function renderLogsFetch() { + if (logsState.loading) { + el.logsFetchStat.textContent = "Refreshing..."; + el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`; + return; + } + + if (logsState.error) { + el.logsFetchStat.textContent = "Needs attention"; + el.logsFetchDetail.textContent = logsState.error; + return; + } + + if (!logsState.updatedAt) { + el.logsFetchStat.textContent = "Idle"; + el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`; + return; + } + + el.logsFetchStat.textContent = `${logsState.duration}ms`; + el.logsFetchDetail.textContent = `${logsState.group || "No group selected"}. Endpoint: ${config.cloudWatchEndpoint}`; + } + + function renderSecretsFetch() { + if (secretsState.loading) { + el.secretsFetchStat.textContent = "Refreshing..."; + el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; + return; + } + + if (secretsState.error) { + el.secretsFetchStat.textContent = "Needs attention"; + el.secretsFetchDetail.textContent = secretsState.error; + return; + } + + if (!secretsState.updatedAt) { + el.secretsFetchStat.textContent = "Idle"; + el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; + return; + } + + el.secretsFetchStat.textContent = `${secretsState.duration}ms`; + el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`; + } + + function renderS3Fetch() { + if (s3State.loading) { + el.s3FetchStat.textContent = "Refreshing..."; + el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`; + return; + } + + if (s3State.error) { + el.s3FetchStat.textContent = "Needs attention"; + el.s3FetchDetail.textContent = s3State.error; + return; + } + + if (!s3State.updatedAt) { + el.s3FetchStat.textContent = "Idle"; + el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`; + return; + } + + el.s3FetchStat.textContent = `${s3State.duration}ms`; + el.s3FetchDetail.textContent = `${s3State.bucket || "No bucket selected"}. Endpoint: ${config.s3Endpoint}`; + } + + function renderStatus() { + el.statusChip.className = "status"; + + if (state.loading) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Refreshing messages..."; + return; + } + + if (state.error) { + el.statusChip.classList.add("bad"); + el.statusChip.textContent = `Refresh failed: ${state.error}`; + return; + } + + if (!state.auto) { + el.statusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.statusChip.classList.add("warn"); + el.statusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!state.updatedAt) { + el.statusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((state.updatedAt + state.interval - Date.now()) / 1000)); + el.statusChip.classList.add("ok"); + el.statusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderLogsStatus() { + el.logsStatusChip.className = "status"; + + if (logsState.loading) { + el.logsStatusChip.classList.add("warn"); + el.logsStatusChip.textContent = "Refreshing logs..."; + return; + } + + if (logsState.error) { + el.logsStatusChip.classList.add("bad"); + el.logsStatusChip.textContent = `Refresh failed: ${logsState.error}`; + return; + } + + if (!logsState.auto) { + el.logsStatusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.logsStatusChip.classList.add("warn"); + el.logsStatusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!logsState.updatedAt) { + el.logsStatusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((logsState.updatedAt + logsState.interval - Date.now()) / 1000)); + el.logsStatusChip.classList.add("ok"); + el.logsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderSecretsStatus() { + el.secretsStatusChip.className = "status"; + + if (secretsState.loading) { + el.secretsStatusChip.classList.add("warn"); + el.secretsStatusChip.textContent = "Refreshing secrets..."; + return; + } + + if (secretsState.error) { + el.secretsStatusChip.classList.add("bad"); + el.secretsStatusChip.textContent = `Refresh failed: ${secretsState.error}`; + return; + } + + if (!secretsState.auto) { + el.secretsStatusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.secretsStatusChip.classList.add("warn"); + el.secretsStatusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!secretsState.updatedAt) { + el.secretsStatusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((secretsState.updatedAt + secretsState.interval - Date.now()) / 1000)); + el.secretsStatusChip.classList.add("ok"); + el.secretsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderS3Status() { + el.s3StatusChip.className = "status"; + + if (s3State.loading) { + el.s3StatusChip.classList.add("warn"); + el.s3StatusChip.textContent = "Refreshing objects..."; + return; + } + + if (s3State.error) { + el.s3StatusChip.classList.add("bad"); + el.s3StatusChip.textContent = `Refresh failed: ${s3State.error}`; + return; + } + + if (!s3State.auto) { + el.s3StatusChip.textContent = "Live refresh paused"; + return; + } + + if (document.hidden) { + el.s3StatusChip.classList.add("warn"); + el.s3StatusChip.textContent = "Tab hidden, live refresh paused"; + return; + } + + if (!s3State.updatedAt) { + el.s3StatusChip.textContent = "Waiting for first refresh..."; + return; + } + + const seconds = Math.max(0, Math.ceil((s3State.updatedAt + s3State.interval - Date.now()) / 1000)); + el.s3StatusChip.classList.add("ok"); + el.s3StatusChip.textContent = `Live refresh on, next check in ${seconds}s`; + } + + function renderLiveClock() { + if (!state.updatedAt) { + el.updatedStat.textContent = "Not refreshed yet"; + return; + } + + el.updatedStat.textContent = `Updated ${formatRelative(state.updatedAt)} via ${state.source}`; + renderStatus(); + } + + function renderLogsLiveClock() { + if (!logsState.updatedAt) { + el.logsUpdatedStat.textContent = "Not refreshed yet"; + return; + } + + el.logsUpdatedStat.textContent = `Updated ${formatRelative(logsState.updatedAt)} via ${logsState.source}`; + renderLogsStatus(); + } + + function renderSecretsLiveClock() { + if (!secretsState.updatedAt) { + el.secretsUpdatedStat.textContent = "Not refreshed yet"; + return; + } + + el.secretsUpdatedStat.textContent = `Updated ${formatRelative(secretsState.updatedAt)} via ${secretsState.source}`; + renderSecretsStatus(); + } + + function renderS3LiveClock() { + if (!s3State.updatedAt) { + el.s3UpdatedStat.textContent = "Not refreshed yet"; + return; + } + + el.s3UpdatedStat.textContent = `Updated ${formatRelative(s3State.updatedAt)} via ${s3State.source}`; + renderS3Status(); + } + + function renderList() { + el.banner.hidden = !state.error; + el.banner.textContent = state.error ? `Refresh failed: ${state.error}` : ""; + + if (!state.filtered.length) { + el.list.innerHTML = ""; + el.empty.hidden = false; + el.empty.textContent = state.messages.length + ? "No messages match the current search." + : "No emails yet. Send one through LocalStack SES and refresh."; + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + return; + } + + el.empty.hidden = true; + el.list.innerHTML = state.filtered.map(renderCard).join(""); + bindCardToggles(); + syncCardExpansion(); + updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); + } + + function renderLogsList() { + el.logsBanner.hidden = !logsState.error; + el.logsBanner.textContent = logsState.error ? `Refresh failed: ${logsState.error}` : ""; + + if (!logsState.group && !logsState.groups.length) { + el.logsList.innerHTML = ""; + el.logsEmpty.hidden = false; + el.logsEmpty.textContent = "No CloudWatch log groups found in LocalStack yet."; + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + return; + } + + if (!logsState.filtered.length) { + el.logsList.innerHTML = ""; + el.logsEmpty.hidden = false; + el.logsEmpty.textContent = logsState.events.length + ? "No log events match the current search." + : "No log events found for the selected group, stream, and time window."; + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + return; + } + + el.logsEmpty.hidden = true; + el.logsList.innerHTML = logsState.filtered.map((event) => renderLogEvent(event)).join(""); + bindLogToggles(); + updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); + } + + function renderSecretsList() { + el.secretsBanner.hidden = !secretsState.error; + el.secretsBanner.textContent = secretsState.error ? `Refresh failed: ${secretsState.error}` : ""; + + if (!secretsState.filtered.length) { + el.secretsList.innerHTML = ""; + el.secretsEmpty.hidden = false; + el.secretsEmpty.textContent = secretsState.items.length + ? "No secrets match the current search." + : "No Secrets Manager entries found in LocalStack yet."; + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + return; + } + + el.secretsEmpty.hidden = true; + el.secretsList.innerHTML = secretsState.filtered.map((secret) => renderSecretCard(secret)).join(""); + bindSecretToggles(); + syncSecretExpansion(); + updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + } + + function renderS3List() { + el.s3Banner.hidden = !s3State.error; + el.s3Banner.textContent = s3State.error ? `Refresh failed: ${s3State.error}` : ""; + + if (!s3State.bucket && !s3State.buckets.length) { + el.s3List.innerHTML = ""; + el.s3Empty.hidden = false; + el.s3Empty.textContent = "No S3 buckets found in LocalStack yet."; + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + return; + } + + if (!s3State.filtered.length) { + el.s3List.innerHTML = ""; + el.s3Empty.hidden = false; + el.s3Empty.textContent = s3State.objects.length + ? "No S3 objects match the current search." + : "No S3 objects found for the selected bucket and prefix."; + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + return; + } + + el.s3Empty.hidden = true; + el.s3List.innerHTML = s3State.filtered.map((object) => renderS3ObjectCard(object)).join(""); + bindS3Toggles(); + syncS3Expansion(); + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); + } + + function renderLogEvent(event) { + const level = detectLogLevel(event); + const levelTag = level ? `${escapeHtml(level.label)}` : ""; + + return ` +
+ +
+
+ ${escapeHtml(formatDateTime(event.timestamp))} + ${escapeHtml(event.logStreamName || "Unknown stream")} + ${levelTag} +
+
+ + ${escapeHtml(formatRelative(event.timestamp || Date.now()))} +
+
+

${renderLogPreviewContent(event)}

+
+
+
${renderLogBodyContent(event.message)}
+
+
+ `; + } + + function renderSecretCard(secret) { + const valueState = secretsState.values[secret.id]; + const tags = []; + + if (secret.owningService) { + tags.push(`${escapeHtml(secret.owningService)}`); + } + + if (secret.primaryRegion) { + tags.push(`${escapeHtml(secret.primaryRegion)}`); + } + + if (secret.rotationEnabled) { + tags.push('Rotation on'); + } else { + tags.push('Rotation off'); + } + + if (secret.tagCount) { + tags.push(`${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}`); + } + + if (valueState?.status === "loaded") { + tags.push(`${escapeHtml(valueState.label)}`); + } + + if (valueState?.status === "error") { + tags.push('Value load failed'); + } + + return ` +
+ +
+
+

๐Ÿ” ${escapeHtml(secret.name)}

+

${escapeHtml(secret.description || secret.arn || "No description")}

+
+ ${escapeHtml(formatDateTime(secret.lastChangedDate || secret.createdDate))} +
+
${tags.join("")}
+

${escapeHtml(buildSecretPreview(secret))}

+
+
+
+ ${metaCard("Name", secret.name)} + ${metaCard("ARN", secret.arn || "Not available")} + ${metaCard("Description", secret.description || "None")} + ${metaCard("Last changed", formatDateTime(secret.lastChangedDate || secret.createdDate))} + ${metaCard("Created", formatDateTime(secret.createdDate))} + ${metaCard("Last accessed", formatDateTime(secret.lastAccessedDate))} + ${metaCard("Primary region", secret.primaryRegion || "Not set")} + ${metaCard("Owning service", secret.owningService || "Not set")} +
+ +
+ ${renderSecretValuePanel(secret, valueState)} +
+
+
+ `; + } + + function renderS3ObjectCard(object) { + const previewState = s3State.previews[object.id]; + const tags = [ + `${escapeHtml(object.bucket)}`, + `${escapeHtml(formatBytes(object.size))}` + ]; + + if (object.storageClass) { + tags.push(`${escapeHtml(object.storageClass)}`); + } + + if (previewState?.status === "loaded") { + tags.push(`${escapeHtml(previewState.previewType)}`); + } + + if (previewState?.status === "error") { + tags.push('Preview failed'); + } + + return ` +
+ +
+
+

๐Ÿชฃ ${escapeHtml(object.key)}

+

${escapeHtml(object.bucket)} โ€ข ${escapeHtml(formatBytes(object.size))}

+
+ ${escapeHtml(formatDateTime(object.lastModified))} +
+
${tags.join("")}
+

${escapeHtml(buildS3Preview(object))}

+
+
+
+
+ + + โฌ‡๏ธ Download +
+
+ +
+ ${metaCard("Bucket", object.bucket)} + ${metaCard("Key", object.key)} + ${metaCard("Size", formatBytes(object.size))} + ${metaCard("Modified", formatDateTime(object.lastModified))} + ${metaCard("Storage class", object.storageClass || "STANDARD")} + ${metaCard("ETag", object.etag || "Not available")} +
+ +
${renderS3PreviewPanel(object, previewState)}
+
+
+ `; + } + + function bindLogToggles() { + el.logsList.querySelectorAll(".logEvent").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + logsState.openIds.add(id); + } else { + logsState.openIds.delete(id); + } + }); + }); + } + + function bindSecretToggles() { + el.secretsList.querySelectorAll(".secretCard").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + secretsState.openIds.add(id); + ensureSecretValue(id, { force: false }); + } else { + secretsState.openIds.delete(id); + } + }); + }); + } + + function bindS3Toggles() { + el.s3List.querySelectorAll(".s3Card").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + s3State.openIds.add(id); + ensureS3Preview(id, { force: false }); + } else { + s3State.openIds.delete(id); + } + }); + }); + } + + function bindCardToggles() { + el.list.querySelectorAll(".card").forEach((details) => { + details.addEventListener("toggle", () => { + const id = details.dataset.id; + + if (!id) { + return; + } + + if (details.open) { + state.openIds.add(id); + } else { + state.openIds.delete(id); + } + + hydrate(details, getMessage(id)); + }); + }); + } + + function renderCard(message) { + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + const tags = []; + + if (state.newIds.has(message.id)) { + tags.push('New'); + } + + if (message.attachmentCount) { + tags.push( + `${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}` + ); + } + + tags.push(`${message.hasHtml ? "HTML" : "Text only"}`); + + if (message.parseError) { + tags.push('Parse issue'); + } + + return ` +
+ +
+
+

${escapeHtml(message.subject)}

+

${escapeHtml(message.from)} to ${escapeHtml(message.to)}

+
+ ${escapeHtml(formatDateTime(message.timestamp))} +
+
${tags.join("")}
+

${escapeHtml(message.preview)}

+
+
+
+
+ ${ + message.hasHtml + ? `` + : "" + } + + +
+
+ +
+
+ +
+ ${metaCard("From", message.from)} + ${metaCard("To", message.to)} + ${metaCard("Reply-To", message.replyTo || "None")} + ${metaCard("Sent", formatDateTime(message.timestamp))} + ${metaCard("Region", message.region || "Unknown region")} + ${metaCard("LocalStack Id", message.id)} + ${metaCard("Message-Id", message.messageId || "Not available")} + ${metaCard("Raw size", formatBytes(message.rawSizeBytes))} + ${message.parseError ? metaCard("Parse error", message.parseError) : ""} +
+ + ${ + message.attachments?.length + ? `
${message.attachments + .map((attachment) => { + const size = attachment.size ? `, ${formatBytes(attachment.size)}` : ""; + const icon = resolveAttachmentIcon(attachment); + return `${icon} ${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})`; + }) + .join("")}
` + : "" + } + +
${renderPanel(message, view)}
+
+
+ `; + } + + function renderPanel(message, view) { + if (view === "rendered" && message.hasHtml) { + return ``; + } + + if (view === "raw") { + const raw = state.raw[message.id]; + + if (!raw) { + return `
Raw MIME source is loaded on demand.
`; + } + + if (raw.status === "loading") { + return '
Loading raw source...
'; + } + + if (raw.status === "error") { + return `
Unable to load raw source: ${escapeHtml(raw.error)}
`; + } + + return `
${escapeHtml(raw.value)}
`; + } + + return `
${escapeHtml(message.textContent || "No plain-text content available for this message.")}
`; + } + + function renderSecretValuePanel(secret, valueState) { + if (!valueState) { + return `
Secret values are loaded on demand.
`; + } + + if (valueState.status === "loading") { + return '
Loading secret value...
'; + } + + if (valueState.status === "error") { + return `
Unable to load secret value: ${escapeHtml(valueState.error)}
`; + } + + const revealed = secretsState.revealedIds.has(secret.id); + const maskedHtml = escapeHtml(maskSecretValue(valueState.copyValue)); + + return ` +
+
+ ${escapeHtml(valueState.label)} + ${ + valueState.versionId + ? `Version ${escapeHtml(valueState.versionId.slice(0, 8))}` + : "" + } + ${ + valueState.versionStages.length + ? `${escapeHtml(valueState.versionStages.join(", "))}` + : "" + } +
+
+ + + + + + +
+
+
${revealed ? valueState.displayHtml : maskedHtml}
+ `; + } + + function renderS3PreviewPanel(object, previewState) { + if (!previewState) { + return `
Object previews load on demand.
`; + } + + if (previewState.status === "loading") { + return '
Loading object preview...
'; + } + + if (previewState.status === "error") { + return `
Unable to load object preview: ${escapeHtml(previewState.error)}
`; + } + + const truncatedTag = previewState.truncated ? 'Preview truncated' : ""; + let previewContent = `
No inline preview available for this object type.
`; + + if (previewState.previewType === "image" && previewState.imageDataUrl) { + previewContent = `${escapeHtml(object.key)}`; + } else if (previewState.previewType === "json") { + previewContent = `
${highlightJsonText(prettyJsonOrText(previewState.previewText))}
`; + } else if (previewState.previewType === "text" || previewState.previewType === "html") { + previewContent = `
${escapeHtml(previewState.previewText || "No preview text available.")}
`; + } + + return ` +
+
+ ${escapeHtml(previewState.previewType)} + ${truncatedTag} + ${previewState.contentType ? `${escapeHtml(previewState.contentType)}` : ""} +
+
+ + โฌ‡๏ธ Download +
+
+ ${previewContent} + `; + } + + function metaCard(label, value) { + return `
${escapeHtml(label)}
${escapeHtml(value)}
`; + } + + function syncCardExpansion() { + const applyCardState = () => { + el.list.querySelectorAll(".card").forEach((details) => { + const id = details.dataset.id; + const shouldOpen = Boolean(id && state.openIds.has(id)); + + if (shouldOpen && !details.open) { + details.open = true; + } + + if (!shouldOpen && details.open) { + details.open = false; + return; + } + + if (shouldOpen) { + hydrate(details, getMessage(id)); + } + }); + }; + + applyCardState(); + window.requestAnimationFrame(applyCardState); + } + + function syncSecretExpansion() { + const applySecretState = () => { + el.secretsList.querySelectorAll(".secretCard").forEach((details) => { + const id = details.dataset.id; + const shouldOpen = Boolean(id && secretsState.openIds.has(id)); + + if (shouldOpen && !details.open) { + details.open = true; + } + + if (!shouldOpen && details.open) { + details.open = false; + return; + } + + if (shouldOpen) { + ensureSecretValue(id, { force: false }); + } + }); + }; + + applySecretState(); + window.requestAnimationFrame(applySecretState); + } + + function syncS3Expansion() { + const applyS3State = () => { + el.s3List.querySelectorAll(".s3Card").forEach((details) => { + const id = details.dataset.id; + const shouldOpen = Boolean(id && s3State.openIds.has(id)); + + if (shouldOpen && !details.open) { + details.open = true; + } + + if (!shouldOpen && details.open) { + details.open = false; + return; + } + + if (shouldOpen) { + ensureS3Preview(id, { force: false }); + } + }); + }; + + applyS3State(); + window.requestAnimationFrame(applyS3State); + } + + function resolveAttachmentIcon(attachment) { + const filename = String(attachment?.filename || "").toLowerCase(); + const contentType = String(attachment?.contentType || "").toLowerCase(); + + if (filename.endsWith(".pdf") || contentType.includes("pdf")) { + return "๐Ÿ“„"; + } + + if ( + [".doc", ".docx", ".txt", ".rtf", ".md"].some((extension) => filename.endsWith(extension)) || + contentType.includes("word") || + contentType.startsWith("text/") + ) { + return "๐Ÿ“"; + } + + if ( + [".xls", ".xlsx", ".csv"].some((extension) => filename.endsWith(extension)) || + contentType.includes("sheet") || + contentType.includes("csv") + ) { + return "๐Ÿ“Š"; + } + + if ( + filename.endsWith(".json") || + filename.endsWith(".xml") || + filename.endsWith(".yaml") || + filename.endsWith(".yml") + ) { + return "๐Ÿงพ"; + } + + if (contentType.startsWith("image/")) { + return "๐Ÿ–ผ๏ธ"; + } + + if (contentType.startsWith("audio/")) { + return "๐ŸŽต"; + } + + if (contentType.startsWith("video/")) { + return "๐ŸŽฌ"; + } + + if ( + [".zip", ".rar", ".7z", ".tar", ".gz"].some((extension) => filename.endsWith(extension)) || + contentType.includes("zip") || + contentType.includes("compressed") + ) { + return "๐Ÿ—œ๏ธ"; + } + + return "๐Ÿ“Ž"; + } + + function hydrate(details, message) { + if (!details || !details.open || !message) { + return; + } + + const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text"); + + if (view !== "rendered" || !message.hasHtml) { + return; + } + + const iframe = details.querySelector("[data-frame]"); + + if (iframe) { + iframe.referrerPolicy = "no-referrer"; + iframe.sandbox = ""; + iframe.srcdoc = message.renderedHtml || ""; + } + } + + function getMessage(id) { + return state.messages.find((message) => message.id === id); + } + + function getLogEvent(id) { + return logsState.events.find((event) => event.id === id); + } + + function getSecret(id) { + return secretsState.items.find((secret) => secret.id === id); + } + + function getS3Object(id) { + return s3State.objects.find((object) => object.id === id); + } + + async function loadRaw(id) { + if (state.raw[id]?.status === "loaded") { + return state.raw[id].value; + } + + if (state.raw[id]?.status === "loading") { + return null; + } + + state.raw[id] = { status: "loading" }; + renderList(); + + try { + const response = await fetch(`/api/messages/${encodeURIComponent(id)}/raw`, { cache: "no-store" }); + + if (!response.ok) { + throw new Error((await response.text()) || `Request failed with ${response.status}`); + } + + const value = await response.text(); + state.raw[id] = { status: "loaded", value }; + return value; + } catch (error) { + state.raw[id] = { status: "error", error: error.message || "Unknown raw load error" }; + setStatus("Could not load the raw message source.", "bad"); + return null; + } finally { + renderList(); + } + } + + async function ensureSecretValue(id, options = {}) { + const { force = false } = options; + + if (!id) { + return null; + } + + if (!force && secretsState.values[id]?.status === "loaded") { + return secretsState.values[id]; + } + + if (secretsState.values[id]?.status === "loading") { + return null; + } + + secretsState.values[id] = { status: "loading" }; + renderSecretsAll(); + + try { + const response = await fetch(`/api/secrets/value?id=${encodeURIComponent(id)}`, { cache: "no-store" }); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const secretString = typeof payload.secretString === "string" ? payload.secretString : ""; + const secretBinary = typeof payload.secretBinary === "string" ? payload.secretBinary : ""; + const parsedString = secretString ? tryParseJsonText(secretString) : { ok: false, value: null }; + const entry = { + status: "loaded", + label: secretBinary ? "Binary" : parsedString.ok ? "JSON" : secretString ? "Text" : "Empty", + copyValue: secretBinary + ? secretBinary + : parsedString.ok + ? JSON.stringify(parsedString.value, null, 2) + : secretString || "No secret value.", + displayHtml: secretBinary + ? escapeHtml(secretBinary) + : parsedString.ok + ? highlightJsonText(JSON.stringify(parsedString.value, null, 2)) + : escapeHtml(secretString || "No secret value."), + isJson: parsedString.ok, + isBinary: Boolean(secretBinary), + versionId: payload.versionId || "", + versionStages: Array.isArray(payload.versionStages) ? payload.versionStages : [], + createdDate: payload.createdDate || "", + arn: payload.arn || "", + name: payload.name || "" + }; + + secretsState.values[id] = entry; + return entry; + } catch (error) { + secretsState.values[id] = { + status: "error", + error: error.message || "Unknown secret value error" + }; + setSecretsStatus("Could not load the secret value.", "bad"); + return null; + } finally { + renderSecretsAll(); + } + } + + async function ensureS3Preview(id, options = {}) { + const { force = false } = options; + + if (!id) { + return null; + } + + if (!force && s3State.previews[id]?.status === "loaded") { + return s3State.previews[id]; + } + + if (s3State.previews[id]?.status === "loading") { + return null; + } + + const object = getS3Object(id); + + if (!object) { + return null; + } + + s3State.previews[id] = { status: "loading" }; + renderS3All(); + + try { + const response = await fetch( + `/api/s3/object?bucket=${encodeURIComponent(object.bucket)}&key=${encodeURIComponent(object.key)}`, + { cache: "no-store" } + ); + + if (!response.ok) { + const payload = await safeJson(response); + throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`); + } + + const payload = await response.json(); + const entry = { + status: "loaded", + previewType: payload.previewType || "binary", + previewText: payload.previewText || "", + imageDataUrl: payload.imageDataUrl || "", + contentType: payload.contentType || "", + contentLength: payload.contentLength || 0, + truncated: Boolean(payload.truncated), + metadata: payload.metadata || {} + }; + + s3State.previews[id] = entry; + return entry; + } catch (error) { + s3State.previews[id] = { + status: "error", + error: error.message || "Unknown S3 preview error" + }; + setS3Status("Could not load the S3 object preview.", "bad"); + return null; + } finally { + renderS3All(); + } + } + + async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + const input = document.createElement("textarea"); + input.value = value; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + } + + function setStatus(message, tone) { + el.statusChip.className = "status"; + + if (tone) { + el.statusChip.classList.add(tone); + } + + el.statusChip.textContent = message; + } + + function setLogsStatus(message, tone) { + el.logsStatusChip.className = "status"; + + if (tone) { + el.logsStatusChip.classList.add(tone); + } + + el.logsStatusChip.textContent = message; + } + + function setSecretsStatus(message, tone) { + el.secretsStatusChip.className = "status"; + + if (tone) { + el.secretsStatusChip.classList.add(tone); + } + + el.secretsStatusChip.textContent = message; + } + + function setS3Status(message, tone) { + el.s3StatusChip.className = "status"; + + if (tone) { + el.s3StatusChip.classList.add(tone); + } + + el.s3StatusChip.textContent = message; + } + + function getInitialPanel() { + const storedPanel = readStoredValue(PANEL_STORAGE_KEY); + return ["emails", "logs", "secrets", "s3"].includes(storedPanel) ? storedPanel : "emails"; + } + + function getInitialTheme() { + const storedTheme = readStoredValue(THEME_STORAGE_KEY); + + if (storedTheme === "dark" || storedTheme === "light") { + return storedTheme; + } + + return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"; + } + + function applyTheme(theme) { + const nextTheme = theme === "dark" ? "dark" : "light"; + appState.theme = nextTheme; + document.body.dataset.theme = nextTheme; + + try { + window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + } catch {} + + renderThemeToggle(); + } + + function renderThemeToggle() { + if (!el.themeToggle) { + return; + } + + const isDark = appState.theme === "dark"; + el.themeToggle.textContent = isDark ? "๐ŸŒ™ Dark theme" : "โ˜€๏ธ Light theme"; + el.themeToggle.setAttribute("aria-pressed", isDark ? "true" : "false"); + el.themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme"); + el.themeToggle.title = isDark ? "Switch to light theme" : "Switch to dark theme"; + } + + function persistPanel() { + writeStoredValue(PANEL_STORAGE_KEY, appState.panel); + } + + function persistEmailPreferences() { + writeStoredJson(EMAIL_PREFERENCES_STORAGE_KEY, { + search: state.search, + auto: state.auto, + interval: state.interval + }); + } + + function persistLogPreferences() { + writeStoredJson(LOG_PREFERENCES_STORAGE_KEY, { + group: logsState.group, + stream: logsState.stream, + search: logsState.search, + auto: logsState.auto, + interval: logsState.interval, + windowMs: logsState.windowMs, + limit: logsState.limit, + wrapLines: logsState.wrapLines, + tailNewest: logsState.tailNewest + }); + } + + function persistSecretPreferences() { + writeStoredJson(SECRET_PREFERENCES_STORAGE_KEY, { + search: secretsState.search, + auto: secretsState.auto, + interval: secretsState.interval + }); + } + + function persistS3Preferences() { + writeStoredJson(S3_PREFERENCES_STORAGE_KEY, { + bucket: s3State.bucket, + prefix: s3State.prefix, + search: s3State.search, + auto: s3State.auto, + interval: s3State.interval + }); + } + + function resetSavedState() { + [ + THEME_STORAGE_KEY, + PANEL_STORAGE_KEY, + EMAIL_PREFERENCES_STORAGE_KEY, + LOG_PREFERENCES_STORAGE_KEY, + SECRET_PREFERENCES_STORAGE_KEY, + S3_PREFERENCES_STORAGE_KEY + ].forEach((key) => { + try { + window.localStorage.removeItem(key); + } catch {} + }); + + window.location.reload(); + } + + function getStoredPreferences(key) { + try { + const rawValue = window.localStorage.getItem(key); + + if (!rawValue) { + return null; + } + + const parsedValue = JSON.parse(rawValue); + return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) ? parsedValue : null; + } catch { + return null; + } + } + + function readStoredValue(key) { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + } + + function writeStoredValue(key, value) { + try { + window.localStorage.setItem(key, String(value)); + } catch {} + } + + function writeStoredJson(key, value) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch {} + } + + function getStoredText(value, fallback = "") { + return typeof value === "string" ? value : fallback; + } + + function getStoredBoolean(value, fallback) { + return typeof value === "boolean" ? value : fallback; + } + + function getStoredNumber(value, allowedValues, fallback) { + const normalizedValue = Number(value); + return Number.isFinite(normalizedValue) && allowedValues.includes(normalizedValue) ? normalizedValue : fallback; + } + + function scrollPaneToTop(element) { + if (!element) { + return; + } + + element.scrollTo({ top: 0, behavior: "smooth" }); + } + + function updatePaneTopButtonVisibility(pane, button) { + if (!pane || !button) { + return; + } + + button.classList.toggle("visible", pane.scrollTop > 140); + } + + function formatDateTime(value) { + if (!value) { + return "Unknown time"; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? "Unknown time" + : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + } + + function formatRelative(timestampMs) { + const seconds = Math.max(1, Math.round((Date.now() - timestampMs) / 1000)); + + if (seconds < 60) { + return `${seconds}s ago`; + } + + const minutes = Math.round(seconds / 60); + + if (minutes < 60) { + return `${minutes}m ago`; + } + + const hours = Math.round(minutes / 60); + + if (hours < 24) { + return `${hours}h ago`; + } + + return `${Math.round(hours / 24)}d ago`; + } + + function formatBytes(value) { + if (!value) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let index = 0; + + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + + return `${index === 0 ? size : size.toFixed(1)} ${units[index]}`; + } + + function formatLogMessage(message) { + const value = String(message || "").trim(); + + if (!value) { + return "No log payload."; + } + + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + + function buildSecretPreview(secret) { + const fragments = []; + + if (secret.description) { + fragments.push(secret.description); + } + + if (secret.owningService) { + fragments.push(`Service: ${secret.owningService}`); + } + + if (secret.primaryRegion) { + fragments.push(`Region: ${secret.primaryRegion}`); + } + + if (secret.tagCount) { + fragments.push(`${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}`); + } + + if (!fragments.length) { + fragments.push("No description or tags yet."); + } + + return fragments.join(" โ€ข "); + } + + function toggleSecretReveal(id) { + if (secretsState.revealedIds.has(id)) { + secretsState.revealedIds.delete(id); + } else { + secretsState.revealedIds.add(id); + } + + renderSecretsAll(); + } + + function maskSecretValue(value) { + const source = String(value || ""); + + if (!source) { + return "Secret is loaded but empty."; + } + + const lines = source.split("\n"); + return lines.map((line) => "โ€ข".repeat(Math.max(12, Math.min(line.length || 0, 48)))).join("\n"); + } + + function buildS3Preview(object) { + const fragments = []; + + if (object.storageClass) { + fragments.push(object.storageClass); + } + + fragments.push(formatBytes(object.size)); + + if (object.etag) { + fragments.push(`ETag ${object.etag.slice(0, 12)}`); + } + + return fragments.join(" โ€ข "); + } + + function prettyJsonOrText(value) { + const parsed = tryParseJsonText(value); + return parsed.ok ? JSON.stringify(parsed.value, null, 2) : String(value || ""); + } + + function detectLogLevel(event) { + const parsed = tryParseJsonText(event?.message); + const candidates = parsed.ok + ? [parsed.value?.level, parsed.value?.severity, parsed.value?.logLevel, parsed.value?.status, parsed.value?.lvl] + : [String(event?.message || "").match(/\b(error|warn|warning|info|debug|trace|fatal)\b/i)?.[0] || ""]; + const normalized = String(candidates.find(Boolean) || "").toLowerCase(); + + if (["fatal", "error", "critical"].includes(normalized)) { + return { label: normalized.toUpperCase(), className: "levelError" }; + } + + if (["warn", "warning"].includes(normalized)) { + return { label: "WARN", className: "levelWarn" }; + } + + if (["info", "notice"].includes(normalized)) { + return { label: normalized.toUpperCase(), className: "levelInfo" }; + } + + if (["debug", "trace"].includes(normalized)) { + return { label: normalized.toUpperCase(), className: "levelDebug" }; + } + + return null; + } + + function renderLogPreviewContent(event) { + const parsedLog = tryParseJsonText(event?.message); + + if (!parsedLog.ok) { + return escapeHtml(event?.preview || "No preview available."); + } + + const compactJson = JSON.stringify(parsedLog.value); + const previewText = compactJson.length > 220 ? `${compactJson.slice(0, 217)}...` : compactJson; + return highlightJsonText(previewText); + } + + function renderLogBodyContent(message) { + const parsedLog = tryParseJsonText(message); + + if (!parsedLog.ok) { + return escapeHtml(formatLogMessage(message)); + } + + return highlightJsonText(JSON.stringify(parsedLog.value, null, 2)); + } + + function tryParseJsonText(message) { + const value = String(message || "").trim(); + + if (!value) { + return { ok: false, value: null }; + } + + try { + return { ok: true, value: JSON.parse(value) }; + } catch { + return { ok: false, value: null }; + } + } + + function highlightJsonText(value) { + const source = String(value ?? ""); + const tokenRegex = + /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g; + let html = ""; + let lastIndex = 0; + + for (const match of source.matchAll(tokenRegex)) { + const [token] = match; + const index = match.index ?? 0; + let className = "jsonNumber"; + + html += escapeHtml(source.slice(lastIndex, index)); + + if (token.endsWith(":")) { + className = "jsonKey"; + } else if (token === "true" || token === "false") { + className = "jsonBoolean"; + } else if (token === "null") { + className = "jsonNull"; + } else if (token.startsWith('"')) { + className = "jsonString"; + } + + html += `${escapeHtml(token)}`; + lastIndex = index + token.length; + } + + html += escapeHtml(source.slice(lastIndex)); + return html; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + async function safeJson(response) { + try { + return await response.json(); + } catch { + return null; + } + } +} diff --git a/_reference/localEmailViewer/server/config.js b/_reference/localEmailViewer/server/config.js new file mode 100644 index 000000000..c5d4ed154 --- /dev/null +++ b/_reference/localEmailViewer/server/config.js @@ -0,0 +1,45 @@ +import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { S3Client } from "@aws-sdk/client-s3"; + +export const PORT = Number(process.env.PORT || 3334); +export const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses"; +export const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000); +export const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000); +export const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566"; +export const CLOUDWATCH_REGION = + process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1"; +export const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development"; +export const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000); +export const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200); +export const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT; +export const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION; +export const S3_ENDPOINT = process.env.S3_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT; +export const S3_REGION = process.env.S3_VIEWER_REGION || CLOUDWATCH_REGION; +export const S3_DEFAULT_BUCKET = process.env.S3_VIEWER_BUCKET || ""; +export const S3_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_PREVIEW_BYTES || 256 * 1024); +export const S3_IMAGE_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_IMAGE_PREVIEW_BYTES || 1024 * 1024); + +export const LOCALSTACK_CREDENTIALS = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test" +}; + +export const cloudWatchLogsClient = new CloudWatchLogsClient({ + region: CLOUDWATCH_REGION, + endpoint: CLOUDWATCH_ENDPOINT, + credentials: LOCALSTACK_CREDENTIALS +}); + +export const secretsManagerClient = new SecretsManagerClient({ + region: SECRETS_REGION, + endpoint: SECRETS_ENDPOINT, + credentials: LOCALSTACK_CREDENTIALS +}); + +export const s3Client = new S3Client({ + region: S3_REGION, + endpoint: S3_ENDPOINT, + credentials: LOCALSTACK_CREDENTIALS, + forcePathStyle: true +}); diff --git a/_reference/localEmailViewer/server/localstack-service.js b/_reference/localEmailViewer/server/localstack-service.js new file mode 100644 index 000000000..ad2b7473f --- /dev/null +++ b/_reference/localEmailViewer/server/localstack-service.js @@ -0,0 +1,845 @@ +import fetch from "node-fetch"; +import { + DescribeLogGroupsCommand, + DescribeLogStreamsCommand, + FilterLogEventsCommand +} from "@aws-sdk/client-cloudwatch-logs"; +import { GetSecretValueCommand, ListSecretsCommand } from "@aws-sdk/client-secrets-manager"; +import { GetObjectCommand, HeadObjectCommand, ListBucketsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; +import { simpleParser } from "mailparser"; +import { + CLOUDWATCH_ENDPOINT, + CLOUDWATCH_REGION, + FETCH_TIMEOUT_MS, + S3_ENDPOINT, + S3_IMAGE_PREVIEW_MAX_BYTES, + S3_PREVIEW_MAX_BYTES, + S3_REGION, + SES_ENDPOINT, + SECRETS_ENDPOINT, + SECRETS_REGION, + cloudWatchLogsClient, + s3Client, + secretsManagerClient +} from "./config.js"; + +async function loadMessages() { + const startedAt = Date.now(); + const sesMessages = await fetchSesMessages(); + const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index))); + + messages.sort((left, right) => { + if ((right.timestampMs || 0) !== (left.timestampMs || 0)) { + return (right.timestampMs || 0) - (left.timestampMs || 0); + } + + return right.index - left.index; + }); + + return { + endpoint: SES_ENDPOINT, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalMessages: messages.length, + parseErrors: messages.filter((message) => Boolean(message.parseError)).length, + latestMessageTimestamp: messages[0]?.timestamp || "", + messages + }; +} + +async function fetchSesMessages() { + const response = await fetch(SES_ENDPOINT, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }); + + if (!response.ok) { + throw new Error(`SES endpoint responded with ${response.status}`); + } + + const data = await response.json(); + return Array.isArray(data.messages) ? data.messages : []; +} + +async function loadLogGroups() { + const groups = []; + let nextToken; + let pageCount = 0; + + do { + const response = await cloudWatchLogsClient.send( + new DescribeLogGroupsCommand({ + nextToken, + limit: 50 + }) + ); + + groups.push( + ...(response.logGroups || []).map((group) => ({ + name: group.logGroupName || "", + arn: group.arn || "", + storedBytes: group.storedBytes || 0, + retentionInDays: group.retentionInDays || 0, + creationTime: group.creationTime || 0 + })) + ); + + nextToken = response.nextToken; + pageCount += 1; + } while (nextToken && pageCount < 10); + + return groups.sort((left, right) => left.name.localeCompare(right.name)); +} + +async function loadLogStreams(logGroupName) { + const streams = []; + let nextToken; + let pageCount = 0; + + do { + const response = await cloudWatchLogsClient.send( + new DescribeLogStreamsCommand({ + logGroupName, + descending: true, + orderBy: "LastEventTime", + nextToken, + limit: 50 + }) + ); + + streams.push( + ...(response.logStreams || []).map((stream) => ({ + name: stream.logStreamName || "", + arn: stream.arn || "", + lastEventTimestamp: stream.lastEventTimestamp || 0, + lastIngestionTime: stream.lastIngestionTime || 0, + storedBytes: stream.storedBytes || 0 + })) + ); + + nextToken = response.nextToken; + pageCount += 1; + } while (nextToken && pageCount < 6 && streams.length < 250); + + return streams; +} + +async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) { + const startedAt = Date.now(); + const eventMap = new Map(); + const startTime = Date.now() - windowMs; + let nextToken; + let previousToken = ""; + let pageCount = 0; + let searchedLogStreams = 0; + + do { + const response = await cloudWatchLogsClient.send( + new FilterLogEventsCommand({ + logGroupName, + logStreamNames: logStreamName ? [logStreamName] : undefined, + startTime, + endTime: Date.now(), + limit, + nextToken + }) + ); + + for (const event of response.events || []) { + const id = + event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`; + + if (!eventMap.has(id)) { + const message = String(event.message || "").trim(); + eventMap.set(id, { + id, + timestamp: event.timestamp || 0, + ingestionTime: event.ingestionTime || 0, + logStreamName: event.logStreamName || "", + message, + preview: buildLogPreview(message) + }); + } + } + + searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length); + previousToken = nextToken || ""; + nextToken = response.nextToken; + pageCount += 1; + } while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit); + + const events = [...eventMap.values()] + .sort((left, right) => { + if ((right.timestamp || 0) !== (left.timestamp || 0)) { + return (right.timestamp || 0) - (left.timestamp || 0); + } + + return left.logStreamName.localeCompare(right.logStreamName); + }) + .slice(0, limit); + + return { + endpoint: CLOUDWATCH_ENDPOINT, + region: CLOUDWATCH_REGION, + logGroupName, + logStreamName, + fetchDurationMs: Date.now() - startedAt, + latestTimestamp: events[0]?.timestamp || 0, + searchedLogStreams, + totalEvents: events.length, + events + }; +} + +async function loadSecrets() { + const startedAt = Date.now(); + const secrets = []; + let nextToken; + let pageCount = 0; + + do { + const response = await secretsManagerClient.send( + new ListSecretsCommand({ + NextToken: nextToken, + MaxResults: 50 + }) + ); + + secrets.push( + ...(response.SecretList || []).map((secret, index) => ({ + id: secret.ARN || secret.Name || `secret-${index}`, + name: secret.Name || "Unnamed secret", + arn: secret.ARN || "", + description: secret.Description || "", + createdDate: normalizeTimestamp(secret.CreatedDate), + lastChangedDate: normalizeTimestamp(secret.LastChangedDate), + lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate), + deletedDate: normalizeTimestamp(secret.DeletedDate), + primaryRegion: secret.PrimaryRegion || "", + owningService: secret.OwningService || "", + rotationEnabled: Boolean(secret.RotationEnabled), + versionCount: Object.keys(secret.SecretVersionsToStages || {}).length, + tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0, + tags: (secret.Tags || []) + .map((tag) => ({ + key: tag.Key || "", + value: tag.Value || "" + })) + .filter((tag) => tag.key || tag.value) + })) + ); + + nextToken = response.NextToken; + pageCount += 1; + } while (nextToken && pageCount < 10 && secrets.length < 500); + + secrets.sort((left, right) => { + const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0; + const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0; + + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + return left.name.localeCompare(right.name); + }); + + return { + endpoint: SECRETS_ENDPOINT, + region: SECRETS_REGION, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalSecrets: secrets.length, + latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "", + secrets + }; +} + +async function loadSecretValue(secretId) { + const startedAt = Date.now(); + const response = await secretsManagerClient.send( + new GetSecretValueCommand({ + SecretId: secretId + }) + ); + + const secretBinary = response.SecretBinary + ? typeof response.SecretBinary === "string" + ? response.SecretBinary + : Buffer.from(response.SecretBinary).toString("base64") + : ""; + + return { + endpoint: SECRETS_ENDPOINT, + region: SECRETS_REGION, + fetchDurationMs: Date.now() - startedAt, + id: secretId, + name: response.Name || "", + arn: response.ARN || "", + versionId: response.VersionId || "", + versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [], + createdDate: normalizeTimestamp(response.CreatedDate), + secretString: typeof response.SecretString === "string" ? response.SecretString : "", + secretBinary + }; +} + +async function loadS3Buckets() { + const startedAt = Date.now(); + const response = await s3Client.send(new ListBucketsCommand({})); + const buckets = (response.Buckets || []) + .map((bucket) => ({ + name: bucket.Name || "", + creationDate: normalizeTimestamp(bucket.CreationDate) + })) + .filter((bucket) => bucket.name) + .sort((left, right) => left.name.localeCompare(right.name)); + + return { + endpoint: S3_ENDPOINT, + region: S3_REGION, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalBuckets: buckets.length, + buckets + }; +} + +async function loadS3Objects({ bucket, prefix }) { + const startedAt = Date.now(); + const objects = []; + let continuationToken; + let pageCount = 0; + + do { + const response = await s3Client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: prefix || undefined, + ContinuationToken: continuationToken, + MaxKeys: 200 + }) + ); + + objects.push( + ...(response.Contents || []).map((object, index) => ({ + id: `${bucket}::${object.Key || index}`, + bucket, + key: object.Key || "", + size: object.Size || 0, + lastModified: normalizeTimestamp(object.LastModified), + etag: String(object.ETag || "").replace(/^"|"$/g, ""), + storageClass: object.StorageClass || "STANDARD" + })) + ); + + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + pageCount += 1; + } while (continuationToken && pageCount < 10 && objects.length < 1000); + + objects.sort((left, right) => { + const leftTime = Date.parse(left.lastModified || 0) || 0; + const rightTime = Date.parse(right.lastModified || 0) || 0; + + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + return left.key.localeCompare(right.key); + }); + + return { + endpoint: S3_ENDPOINT, + region: S3_REGION, + bucket, + prefix, + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + totalObjects: objects.length, + latestTimestamp: objects[0]?.lastModified || "", + objects + }; +} + +async function loadS3ObjectPreview({ bucket, key }) { + const startedAt = Date.now(); + const head = await s3Client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key + }) + ); + + const contentType = head.ContentType || guessObjectContentType(key); + const contentLength = Number(head.ContentLength || 0); + const previewType = resolveS3PreviewType(contentType, key); + const result = { + endpoint: S3_ENDPOINT, + region: S3_REGION, + bucket, + key, + fetchDurationMs: 0, + contentType, + contentLength, + etag: String(head.ETag || "").replace(/^"|"$/g, ""), + lastModified: normalizeTimestamp(head.LastModified), + metadata: head.Metadata || {}, + previewType, + previewText: "", + imageDataUrl: "", + truncated: false + }; + + const shouldLoadTextPreview = previewType === "json" || previewType === "text" || previewType === "html"; + const shouldLoadImagePreview = + previewType === "image" && contentLength > 0 && contentLength <= S3_IMAGE_PREVIEW_MAX_BYTES; + + if ((shouldLoadTextPreview || shouldLoadImagePreview) && contentLength > 0) { + const previewBytes = Math.max(1, Math.min(contentLength || S3_PREVIEW_MAX_BYTES, S3_PREVIEW_MAX_BYTES)); + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: bucket, + Key: key, + Range: `bytes=0-${previewBytes - 1}` + }) + ); + const content = Buffer.from(await response.Body.transformToByteArray()); + result.truncated = contentLength > content.length; + + if (shouldLoadImagePreview) { + result.imageDataUrl = `data:${contentType};base64,${content.toString("base64")}`; + } else { + result.previewText = content.toString("utf8"); + } + } + + result.fetchDurationMs = Date.now() - startedAt; + return result; +} + +async function loadServiceHealthSummary() { + const startedAt = Date.now(); + const [sesResult, logsResult, secretsResult, s3Result] = await Promise.allSettled([ + fetchSesMessages(), + loadLogGroups(), + loadSecrets(), + loadS3Buckets() + ]); + + return { + fetchedAt: new Date().toISOString(), + fetchDurationMs: Date.now() - startedAt, + services: { + emails: summarizeHealthResult({ + icon: "โœ‰๏ธ", + panel: "emails", + label: "SES Emails", + result: sesResult, + count: sesResult.status === "fulfilled" ? sesResult.value.length : 0, + detail: SES_ENDPOINT, + noun: "email" + }), + logs: summarizeHealthResult({ + icon: "๐Ÿ“œ", + panel: "logs", + label: "CloudWatch Logs", + result: logsResult, + count: logsResult.status === "fulfilled" ? logsResult.value.length : 0, + detail: `${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`, + noun: "group" + }), + secrets: summarizeHealthResult({ + icon: "๐Ÿ”", + panel: "secrets", + label: "Secrets Manager", + result: secretsResult, + count: secretsResult.status === "fulfilled" ? secretsResult.value.totalSecrets : 0, + detail: `${SECRETS_ENDPOINT} (${SECRETS_REGION})`, + noun: "secret" + }), + s3: summarizeHealthResult({ + icon: "๐Ÿชฃ", + panel: "s3", + label: "S3 Explorer", + result: s3Result, + count: s3Result.status === "fulfilled" ? s3Result.value.totalBuckets : 0, + detail: `${S3_ENDPOINT} (${S3_REGION})`, + noun: "bucket" + }) + } + }; +} + +async function findSesMessageById(id) { + const messages = await fetchSesMessages(); + return messages.find((message, index) => resolveMessageId(message, index) === id) || null; +} + +async function parseSesMessageById(id) { + const message = await findSesMessageById(id); + + if (!message) { + return null; + } + + return simpleParser(message.RawData || ""); +} + +async function toMessageViewModel(message, index) { + const id = resolveMessageId(message, index); + + try { + const parsed = await simpleParser(message.RawData || ""); + const textContent = normalizeText(parsed.text || ""); + const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || ""); + const timestamp = normalizeTimestamp(message.Timestamp || parsed.date); + + return { + id, + index, + from: formatAddressList(parsed.from) || message.Source || "Unknown sender", + to: formatAddressList(parsed.to) || "No To Address", + replyTo: formatAddressList(parsed.replyTo), + subject: parsed.subject || "No Subject", + region: message.Region || "", + timestamp, + timestampMs: timestamp ? Date.parse(timestamp) : 0, + messageId: parsed.messageId || "", + rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), + attachmentCount: parsed.attachments.length, + attachments: parsed.attachments.map((attachment, attachmentIndex) => ({ + index: attachmentIndex, + filename: resolveAttachmentFilename(attachment, attachmentIndex), + contentType: attachment.contentType || "application/octet-stream", + size: attachment.size || 0 + })), + preview: buildPreview(textContent, renderedHtml), + textContent, + renderedHtml, + hasHtml: Boolean(renderedHtml), + parseError: "" + }; + } catch (error) { + return { + id, + index, + from: message.Source || "Unknown sender", + to: "Unknown recipient", + replyTo: "", + subject: "Unable to parse message", + region: message.Region || "", + timestamp: normalizeTimestamp(message.Timestamp), + timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0, + messageId: "", + rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"), + attachmentCount: 0, + attachments: [], + preview: "This message could not be parsed. Open the raw view to inspect the MIME source.", + textContent: "", + renderedHtml: "", + hasHtml: false, + parseError: error.message + }; + } +} + +function resolveMessageId(message, index = 0) { + return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`; +} + +function resolveAttachmentFilename(attachment, index = 0) { + if (attachment?.filename) { + return attachment.filename; + } + + return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`; +} + +function attachmentExtension(contentType) { + const normalized = String(contentType || "") + .split(";")[0] + .trim() + .toLowerCase(); + + return ( + { + "application/json": ".json", + "application/pdf": ".pdf", + "application/zip": ".zip", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "text/calendar": ".ics", + "text/csv": ".csv", + "text/html": ".html", + "text/plain": ".txt" + }[normalized] || "" + ); +} + +function buildAttachmentDisposition(filename) { + const fallback = String(filename || "attachment") + .replace(/[^\x20-\x7e]/g, "_") + .replace(/["\\]/g, "_"); + + return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`; +} + +function buildInlineDisposition(filename) { + const fallback = String(filename || "file") + .replace(/[^\x20-\x7e]/g, "_") + .replace(/["\\]/g, "_"); + + return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "file")}`; +} + +function basenameFromKey(key) { + const value = String(key || ""); + const parts = value.split("/").filter(Boolean); + return parts[parts.length - 1] || "file"; +} + +function guessObjectContentType(key) { + const normalizedKey = String(key || "").toLowerCase(); + + if (normalizedKey.endsWith(".json")) { + return "application/json"; + } + + if (normalizedKey.endsWith(".csv")) { + return "text/csv"; + } + + if (normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) { + return "text/html"; + } + + if (normalizedKey.endsWith(".txt") || normalizedKey.endsWith(".log") || normalizedKey.endsWith(".md")) { + return "text/plain"; + } + + if (normalizedKey.endsWith(".png")) { + return "image/png"; + } + + if (normalizedKey.endsWith(".jpg") || normalizedKey.endsWith(".jpeg")) { + return "image/jpeg"; + } + + if (normalizedKey.endsWith(".gif")) { + return "image/gif"; + } + + if (normalizedKey.endsWith(".webp")) { + return "image/webp"; + } + + if (normalizedKey.endsWith(".svg")) { + return "image/svg+xml"; + } + + if (normalizedKey.endsWith(".pdf")) { + return "application/pdf"; + } + + return "application/octet-stream"; +} + +function resolveS3PreviewType(contentType, key) { + const normalizedType = String(contentType || "").toLowerCase(); + const normalizedKey = String(key || "").toLowerCase(); + + if (normalizedType.includes("json") || normalizedKey.endsWith(".json")) { + return "json"; + } + + if (normalizedType.startsWith("image/")) { + return "image"; + } + + if (normalizedType.includes("html") || normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) { + return "html"; + } + + if ( + normalizedType.startsWith("text/") || + [".txt", ".log", ".csv", ".xml", ".yml", ".yaml", ".md"].some((extension) => normalizedKey.endsWith(extension)) + ) { + return "text"; + } + + return "binary"; +} + +function summarizeHealthResult({ icon, panel, label, result, count, detail, noun }) { + if (result.status === "fulfilled") { + return { + ok: true, + icon, + panel, + label, + count, + summary: `${count} ${noun}${count === 1 ? "" : "s"}`, + detail + }; + } + + return { + ok: false, + icon, + panel, + label, + count: 0, + summary: "Needs attention", + detail: result.reason?.message || detail + }; +} + +function normalizeTimestamp(value) { + if (!value) { + return ""; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toISOString(); +} + +function normalizeText(value) { + return String(value || "") + .replace(/\r\n/g, "\n") + .trim(); +} + +function buildPreview(textContent, renderedHtml) { + const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim(); + + if (!source) { + return "No message preview available."; + } + + return source.length > 220 ? `${source.slice(0, 217)}...` : source; +} + +function buildLogPreview(message) { + const source = String(message || "") + .replace(/\s+/g, " ") + .trim(); + + if (!source) { + return "No log preview available."; + } + + return source.length > 220 ? `${source.slice(0, 217)}...` : source; +} + +function clampNumber(value, fallback, min, max) { + const parsed = Number(value); + + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Math.min(Math.max(parsed, min), max); +} + +function buildRenderedHtml(html) { + if (!html) { + return ""; + } + + const value = String(html); + const hasDocument = /]/i.test(value) || / + + + + + + + + ${value} +`; +} + +function stripTags(value) { + return String(value || "") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " "); +} + +function formatAddressList(addresses) { + if (!addresses?.value?.length) { + return ""; + } + + return addresses.value + .map(({ name, address }) => { + if (name && address) { + return `${name} <${address}>`; + } + + return address || name || ""; + }) + .filter(Boolean) + .join(", "); +} + +async function loadMessageAttachment(messageId, attachmentIndex) { + const parsed = await parseSesMessageById(messageId); + + if (!parsed) { + return null; + } + + const attachment = parsed.attachments?.[attachmentIndex]; + + if (!attachment) { + return null; + } + + return { + filename: resolveAttachmentFilename(attachment, attachmentIndex), + contentType: attachment.contentType || "application/octet-stream", + content: Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || "") + }; +} + +async function loadS3ObjectDownload({ bucket, key }) { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: bucket, + Key: key + }) + ); + + return { + filename: basenameFromKey(key), + contentType: response.ContentType || guessObjectContentType(key), + content: Buffer.from(await response.Body.transformToByteArray()) + }; +} + +export { + buildAttachmentDisposition, + buildInlineDisposition, + clampNumber, + findSesMessageById, + loadLogEvents, + loadLogGroups, + loadLogStreams, + loadMessageAttachment, + loadMessages, + loadS3Buckets, + loadS3ObjectDownload, + loadS3ObjectPreview, + loadS3Objects, + loadSecretValue, + loadSecrets, + loadServiceHealthSummary +}; diff --git a/_reference/localEmailViewer/server/page.js b/_reference/localEmailViewer/server/page.js new file mode 100644 index 000000000..15cb6deea --- /dev/null +++ b/_reference/localEmailViewer/server/page.js @@ -0,0 +1,495 @@ +import { + CLOUDWATCH_DEFAULT_GROUP, + CLOUDWATCH_DEFAULT_LIMIT, + CLOUDWATCH_DEFAULT_WINDOW_MS, + CLOUDWATCH_ENDPOINT, + CLOUDWATCH_REGION, + DEFAULT_REFRESH_MS, + S3_DEFAULT_BUCKET, + S3_ENDPOINT, + S3_REGION, + SECRETS_ENDPOINT, + SECRETS_REGION, + SES_ENDPOINT +} from "./config.js"; + +function getClientConfig() { + return { + defaultRefreshMs: DEFAULT_REFRESH_MS, + endpoint: SES_ENDPOINT, + cloudWatchEndpoint: CLOUDWATCH_ENDPOINT, + cloudWatchRegion: CLOUDWATCH_REGION, + secretsEndpoint: SECRETS_ENDPOINT, + secretsRegion: SECRETS_REGION, + s3Endpoint: S3_ENDPOINT, + s3Region: S3_REGION, + defaultS3Bucket: S3_DEFAULT_BUCKET, + defaultLogGroup: CLOUDWATCH_DEFAULT_GROUP, + defaultLogWindowMs: CLOUDWATCH_DEFAULT_WINDOW_MS, + defaultLogLimit: CLOUDWATCH_DEFAULT_LIMIT + }; +} + +function renderHtml() { + return ` + + + + + LocalStack Inspector + + + +
+
+
+
+

LocalStack Toolbox

+

Inspector

+
+
+
+ + +
+
+
+ Stack +
+ +
+
+
+ +
+
+
+ + + + Waiting for first refresh... +
+
+ + + + +
+
+ +
+
Total00 visible
+
New0New since last refresh
+
NewestNo messagesNot refreshed yet
+
FetchIdleEndpoint: ${escapeHtml(SES_ENDPOINT)}
+
+ +
+
+ + +
+
+ +
+
+
+
+ + + + + + +
+ + + +`; +} + +function renderStyles() { + return ` + :root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--log-shell:linear-gradient(180deg,rgba(239,246,255,.98),rgba(248,251,255,.99));--log-body:#f8fbff;--secret-shell:linear-gradient(180deg,rgba(239,251,246,.98),rgba(247,253,249,.99));--secret-body:#f6fcf8;--bucket-shell:linear-gradient(180deg,rgba(255,249,232,.98),rgba(255,252,243,.99));--bucket-body:#fffcf2;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--secret-line:rgba(31,143,101,.2);--bucket-line:rgba(181,137,37,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--secret:#1f8f65;--secret-soft:rgba(31,143,101,.1);--bucket:#9d6b00;--bucket-soft:rgba(181,137,37,.12);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);--secret-shadow:0 16px 32px rgba(31,143,101,.12);--bucket-shadow:0 16px 32px rgba(181,137,37,.12);} + *{box-sizing:border-box} + html,body{margin:0;height:100%;overflow:hidden} + body{color-scheme:light;background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif;transition:background-color .18s ease,color .18s ease} + button,input,select,textarea{font:inherit} + button{cursor:pointer} + .page{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;max-width:1360px;height:100vh;height:100dvh;margin:0 auto;padding:14px;overflow:hidden} + .hero{display:block;margin-bottom:0} + .heroShell,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)} + .card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)} + .heroShell,.toolControls{border-radius:18px} + .heroShell{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px} + .toolControls{padding:12px} + .heroIdentity{display:grid;gap:3px;min-width:0} + .eyebrow{margin:0 0 4px;color:var(--accent);font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase} + h1{margin:0;font-size:clamp(1.8rem,3.6vw,2.85rem);line-height:.96;letter-spacing:-.05em} + .lede{margin:8px 0 0;max-width:54ch;color:var(--muted);font-size:.92rem} + .heroTopRow{display:flex;flex:1 1 360px;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} + .heroActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} + .heroStatusRow{display:flex;flex:1 1 100%;flex-wrap:wrap;gap:8px;align-items:center} + .heroStatusLabel{color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase} + .helper{margin:0;color:var(--muted);font-size:.89rem} + .healthStrip{display:flex;flex:1 1 520px;flex-wrap:wrap;gap:6px;align-items:center;min-width:0} + .healthBadge{display:inline-flex;align-items:center;gap:8px;min-height:30px;max-width:100%;padding:0 10px;border-radius:999px;border:1px solid rgba(31,41,51,.1);background:rgba(255,255,255,.78);box-shadow:0 8px 18px rgba(15,23,42,.06);text-align:left;transition:transform .12s ease,background-color .12s ease,border-color .12s ease,box-shadow .12s ease} + .healthBadgeName{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:800;white-space:nowrap} + .healthBadgeSummary{min-width:0;overflow:hidden;color:var(--muted);font-size:.78rem;font-weight:700;text-overflow:ellipsis;white-space:nowrap} + .healthBadge.ok{border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.1)} + .healthBadge.bad{border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.1)} + .healthBadge.warn{border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.1)} + .healthBadge.active{border-color:rgba(207,109,60,.28);background:rgba(207,109,60,.16);box-shadow:0 10px 24px rgba(207,109,60,.12)} + .healthBadge.active .healthBadgeName,.healthBadge.active .healthBadgeSummary{color:var(--ink)} + .healthRefreshButton{flex:0 0 auto;padding:0 10px} + .primary,.ghost,.mini,.tab{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} + .themeToggle{white-space:nowrap} + .workspacePanel{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:6px;min-height:0} + .workspacePanel[hidden]{display:none} + .toolControls{display:grid;gap:8px} + .contentPane{height:100%;min-height:0;overflow:auto;scroll-behavior:smooth;padding-right:4px} + .contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px} + .paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px} + .paneTopButton{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:999px;border:1px solid rgba(255,255,255,.32);background:rgba(31,41,51,.42);color:#fff;font-size:1.1rem;line-height:1;backdrop-filter:blur(12px);box-shadow:0 10px 24px rgba(31,41,51,.18);opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none;transition:opacity .16s ease,transform .16s ease,background-color .12s ease;z-index:6} + .paneTopButton.visible{opacity:.78;transform:translateY(0);visibility:visible;pointer-events:auto} + .paneTopButton.visible:hover{opacity:1;background:rgba(31,41,51,.62);transform:translateY(-1px)} + .row{display:flex;flex-wrap:wrap;gap:6px;align-items:center} + .primary,.ghost{min-height:34px;padding:0 12px;font-weight:700} + .mini,.tab{min-height:28px;padding:0 10px;font-weight:600} + .primary{background:var(--accent);color:#fff7f2} + .ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)} + .tab{background:transparent;color:var(--muted)} + .tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)} + .primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)} + .chip{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.88rem} + .chip input{margin:0;accent-color:var(--accent)} + .chip select{border:none;background:transparent;outline:none;color:var(--ink)} + .search{flex:1 1 260px;min-height:36px;padding:0 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none} + .searchCompact{flex:1 1 220px} + .status{display:inline-flex;align-items:center;min-height:32px;padding:0 11px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.86rem;font-weight:600} + .status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)} + .status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)} + .status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)} + .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:0} + .stat{border-radius:16px;padding:10px 12px} + .stat span{display:block;margin-bottom:4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em} + .stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em} + .stat small{display:block;margin-top:4px;color:var(--muted);font-size:.82rem} + .banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)} + .banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)} + .list{display:grid;gap:12px;align-content:start} + .logList{display:grid;gap:10px;align-content:start;width:100%} + .card{overflow:hidden;border-radius:16px} + .card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)} + .summary{list-style:none;display:grid;gap:7px;padding:12px 14px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))} + .summary::-webkit-details-marker{display:none} + .top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center} + .top{justify-content:space-between} + .head{min-width:0;flex:1 1 320px} + .head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word} + .meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word} + .time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700} + .time{background:rgba(31,41,51,.06)} + .tag{background:var(--accent-soft);color:#8d5632} + .tag.new{background:rgba(31,143,101,.1);color:var(--ok)} + .tag.bad{background:rgba(179,58,58,.1);color:var(--bad)} + .preview{margin:0;color:#324150;font-size:.9rem} + .body{display:grid;gap:10px;padding:10px 14px 14px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)} + .toolbar{justify-content:space-between;align-items:center} + .tabs{display:inline-flex;gap:4px;padding:3px;border-radius:999px;background:rgba(207,109,60,.08)} + .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px} + .metaCard{padding:9px 11px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)} + .metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase} + .metaCard dd{margin:0;word-break:break-word} + .attachments{gap:6px} + .attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem} + .attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease} + .attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)} + .panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff} + .logEvent{width:100%;overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)} + .secretCard{background:var(--secret-shell);border:1px solid var(--secret-line);box-shadow:var(--secret-shadow)} + .s3Card{background:var(--bucket-shell);border:1px solid var(--bucket-line);box-shadow:var(--bucket-shadow)} + .logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer} + .logSummary::-webkit-details-marker{display:none} + .secretSummary{background:linear-gradient(180deg,rgba(244,253,248,.9),rgba(236,249,242,.96))} + .s3Summary{background:linear-gradient(180deg,rgba(255,251,238,.92),rgba(255,246,223,.98))} + .logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center} + .logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center} + .logSummaryActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end} + .logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .secretTag{background:var(--secret-soft);color:var(--secret);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bucketTag{background:var(--bucket-soft);color:var(--bucket);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)} + .secretBody{border-top-color:rgba(31,143,101,.14);background:var(--secret-body)} + .s3Body{border-top-color:rgba(181,137,37,.18);background:var(--bucket-body)} + .logCopyButton{box-shadow:none} + .logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff} + .secretValuePanel{display:grid;gap:10px} + .secretValuePanel pre{border-radius:12px;border:1px solid rgba(31,143,101,.14);padding:12px;background:linear-gradient(180deg,rgba(31,143,101,.04),transparent 140px),#fff} + .s3PreviewPanel{display:grid;gap:10px} + .s3PreviewImage{max-width:min(100%,640px);border-radius:12px;border:1px solid rgba(181,137,37,.16);background:#fff} + .logBody.wrapOff pre{white-space:pre;word-break:normal} + .tag.levelError{background:rgba(179,58,58,.12);color:var(--bad)} + .tag.levelWarn{background:rgba(157,95,0,.12);color:var(--warn)} + .tag.levelInfo{background:rgba(48,113,169,.12);color:var(--info)} + .tag.levelDebug{background:rgba(96,112,128,.12);color:var(--muted)} + .jsonSyntax .jsonKey{color:#b55f2d} + .jsonSyntax .jsonString{color:#1f8f65} + .jsonSyntax .jsonNumber{color:#2f6ea9} + .jsonSyntax .jsonBoolean{color:#9d5f00} + .jsonSyntax .jsonNull{color:#b33a3a} + iframe{width:100%;min-height:560px;border:none;background:#fff} + pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff} + .placeholder,.inlineError{padding:12px} + .inlineError{color:var(--bad)} + body[data-theme="dark"]{color-scheme:dark;background:radial-gradient(circle at top left,rgba(207,109,60,.12),transparent 28%),radial-gradient(circle at top right,rgba(48,113,169,.12),transparent 26%),linear-gradient(180deg,#10161d,#17202a)} + body[data-theme="dark"] .heroShell, + body[data-theme="dark"] .toolControls, + body[data-theme="dark"] .stat{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.16);box-shadow:0 14px 30px rgba(0,0,0,.32)} + body[data-theme="dark"] .card{background:linear-gradient(180deg,rgba(50,35,28,.96),rgba(31,25,22,.98));border-color:rgba(207,109,60,.24);box-shadow:0 18px 34px rgba(0,0,0,.34)} + body[data-theme="dark"] .logEvent{background:linear-gradient(180deg,rgba(18,31,45,.96),rgba(14,24,36,.98));border-color:rgba(73,144,204,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)} + body[data-theme="dark"] .secretCard{background:linear-gradient(180deg,rgba(19,39,31,.96),rgba(14,30,24,.98));border-color:rgba(64,170,126,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)} + body[data-theme="dark"] .s3Card{background:linear-gradient(180deg,rgba(52,42,17,.96),rgba(37,30,13,.98));border-color:rgba(181,137,37,.24);box-shadow:0 16px 32px rgba(0,0,0,.34)} + body[data-theme="dark"] .healthBadge{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.18);box-shadow:0 10px 22px rgba(0,0,0,.28)} + body[data-theme="dark"] .healthBadge.active{border-color:rgba(207,109,60,.32);background:rgba(207,109,60,.18);box-shadow:0 12px 26px rgba(0,0,0,.3)} + body[data-theme="dark"] .healthBadge.active .healthBadgeName, + body[data-theme="dark"] .healthBadge.active .healthBadgeSummary{color:#f8ede6} + body[data-theme="dark"] .tab{color:#aab8c8} + body[data-theme="dark"] .tab.active, + body[data-theme="dark"] .ghost, + body[data-theme="dark"] .mini, + body[data-theme="dark"] .chip, + body[data-theme="dark"] .status, + body[data-theme="dark"] .search{background:rgba(18,25,35,.88);border-color:rgba(148,163,184,.18);color:#edf2f7} + body[data-theme="dark"] .chip select, + body[data-theme="dark"] .search::placeholder{color:#9fb0c2} + body[data-theme="dark"] .ghost, + body[data-theme="dark"] .mini, + body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)} + body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))} + body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)} + body[data-theme="dark"] .logSummary{background:linear-gradient(180deg,rgba(21,34,47,.94),rgba(16,27,39,.98))} + body[data-theme="dark"] .logBody{background:#13212d;border-top-color:rgba(73,144,204,.18)} + body[data-theme="dark"] .secretSummary{background:linear-gradient(180deg,rgba(21,43,34,.94),rgba(16,34,27,.98))} + body[data-theme="dark"] .secretBody{background:#12241c;border-top-color:rgba(64,170,126,.18)} + body[data-theme="dark"] .s3Summary{background:linear-gradient(180deg,rgba(53,41,19,.94),rgba(39,31,15,.98))} + body[data-theme="dark"] .s3Body{background:#241d10;border-top-color:rgba(181,137,37,.18)} + body[data-theme="dark"] .metaCard{background:rgba(17,25,35,.64);border-color:rgba(148,163,184,.14)} + body[data-theme="dark"] .attachment{background:rgba(50,35,28,.9);border-color:rgba(207,109,60,.18)} + body[data-theme="dark"] .attachmentLink{color:#f6c4a9} + body[data-theme="dark"] .attachmentLink:hover{background:rgba(75,52,39,.96);border-color:rgba(246,196,169,.26)} + body[data-theme="dark"] .panel, + body[data-theme="dark"] pre, + body[data-theme="dark"] .logBody pre{background:linear-gradient(180deg,rgba(73,144,204,.06),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .secretValuePanel pre{background:linear-gradient(180deg,rgba(64,170,126,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .s3PreviewPanel pre{background:linear-gradient(180deg,rgba(181,137,37,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .panel{border-color:rgba(148,163,184,.14)} + body[data-theme="dark"] .banner, + body[data-theme="dark"] .empty{background:rgba(15,21,30,.82);border-color:rgba(148,163,184,.16)} + body[data-theme="dark"] .time{background:rgba(148,163,184,.12);color:#e8edf3} + body[data-theme="dark"] .tag{background:rgba(207,109,60,.14);color:#f0c2aa} + body[data-theme="dark"] .logTag{background:rgba(73,144,204,.16);color:#93cfff} + body[data-theme="dark"] .secretTag{background:rgba(64,170,126,.16);color:#9fe0be} + body[data-theme="dark"] .bucketTag{background:rgba(181,137,37,.16);color:#f1d38c} + body[data-theme="dark"] .preview, + body[data-theme="dark"] .logPreview, + body[data-theme="dark"] .metaCard dd, + body[data-theme="dark"] .head h2, + body[data-theme="dark"] .stat strong, + body[data-theme="dark"] h1{color:#edf2f7} + body[data-theme="dark"] .jsonSyntax .jsonKey{color:#f0b08a} + body[data-theme="dark"] .jsonSyntax .jsonString{color:#80d5b0} + body[data-theme="dark"] .jsonSyntax .jsonNumber{color:#94c9ff} + body[data-theme="dark"] .jsonSyntax .jsonBoolean{color:#f0c274} + body[data-theme="dark"] .jsonSyntax .jsonNull{color:#ff9c9c} + body[data-theme="dark"] .meta, + body[data-theme="dark"] .helper, + body[data-theme="dark"] .lede, + body[data-theme="dark"] .stat small, + body[data-theme="dark"] .stat span, + body[data-theme="dark"] .chip, + body[data-theme="dark"] .tab{color:#aab8c8} + body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7} + body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)} + @media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row,.heroActions{align-items:stretch}.heroTopRow{justify-content:stretch;flex-basis:100%}.heroStatusRow{align-items:flex-start}.heroStatusLabel,.healthStrip{flex-basis:100%}.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.healthBadge{justify-content:flex-start}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}} + `; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export { getClientConfig, renderHtml };