diff --git a/.gitignore b/.gitignore index 4b0d51183..59c48f34d 100644 --- a/.gitignore +++ b/.gitignore @@ -142,8 +142,6 @@ docker_data /CLAUDE.md /COPILOT.md /GEMINI.md -/_reference/select-component-test-plan.md - /.cursorrules /AGENTS.md /AI_CONTEXT.md @@ -151,4 +149,3 @@ docker_data /COPILOT.md /.github/copilot-instructions.md /GEMINI.md -/_reference/select-component-test-plan.md diff --git a/_reference/localEmailViewer/README.md b/_reference/localEmailViewer/README.md index 6054686fd..cbfe9e555 100644 --- a/_reference/localEmailViewer/README.md +++ b/_reference/localEmailViewer/README.md @@ -1,5 +1,9 @@ -This app connects to your Docker LocalStack SES endpoint and gives you a local inbox-style viewer -for generated emails. +This app connects to your Docker LocalStack endpoints and gives you a compact inspector for: + +- SES generated emails +- CloudWatch log groups, streams, and recent events +- Secrets Manager secrets and values +- S3 buckets and object previews ```shell npm start @@ -15,13 +19,26 @@ Open: http://localhost:3334 Features: -- Manual refresh -- Live refresh with adjustable polling interval -- Search across subject, addresses, preview text, and attachment names -- Expand/collapse all messages -- Rendered HTML, plain-text, and raw MIME views -- Copy raw MIME source -- New-message highlighting plus fetch timing and parse-error stats +- SES email workspace with manual refresh, live refresh, search, HTML/text/raw views, + attachment downloads, and new-message highlighting +- CloudWatch Logs workspace with log group selection, stream filtering, adjustable time window, + adjustable event limit, live refresh, in-browser log search, log-level highlighting, wrap toggle, + and optional tail-to-newest mode +- Secrets Manager workspace with live refresh, search, expandable secret metadata, lazy-loaded + secret values, masked-by-default secret viewing, and quick copy actions +- S3 Explorer workspace with bucket selection, prefix filtering, object search, lazy object + previews, + object key/URI copy actions, and downloads +- 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: @@ -30,4 +47,16 @@ PORT=3334 SES_VIEWER_ENDPOINT=http://localhost:4566/_aws/ses SES_VIEWER_REFRESH_MS=10000 SES_VIEWER_FETCH_TIMEOUT_MS=5000 +CLOUDWATCH_VIEWER_ENDPOINT=http://localhost:4566 +CLOUDWATCH_VIEWER_REGION=ca-central-1 +CLOUDWATCH_VIEWER_LOG_GROUP=development +CLOUDWATCH_VIEWER_WINDOW_MS=900000 +CLOUDWATCH_VIEWER_LIMIT=200 +SECRETS_VIEWER_ENDPOINT=http://localhost:4566 +SECRETS_VIEWER_REGION=ca-central-1 +S3_VIEWER_ENDPOINT=http://localhost:4566 +S3_VIEWER_REGION=ca-central-1 +S3_VIEWER_BUCKET= +S3_VIEWER_PREVIEW_BYTES=262144 +S3_VIEWER_IMAGE_PREVIEW_BYTES=1048576 ``` diff --git a/_reference/localEmailViewer/index.js b/_reference/localEmailViewer/index.js index 15b7736a9..67df19055 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -1,13 +1,41 @@ import express from "express"; -import fetch from "node-fetch"; -import { simpleParser } from "mailparser"; +import { readFileSync } from "node:fs"; +import { + 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 { + 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 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"); @@ -19,18 +47,36 @@ 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) => { res.json({ ok: true, endpoint: SES_ENDPOINT, + endpoints: { + ses: SES_ENDPOINT, + cloudWatchLogs: CLOUDWATCH_ENDPOINT, + secretsManager: SECRETS_ENDPOINT, + s3: S3_ENDPOINT + }, port: PORT, defaultRefreshMs: DEFAULT_REFRESH_MS }); }); +app.get("/api/service-health", async (req, res) => { + try { + res.json(await loadServiceHealthSummary()); + } catch (error) { + console.error("Error fetching service health:", error); + res.status(502).json({ + error: "Unable to fetch LocalStack service health", + details: error.message + }); + } +}); + app.get("/api/messages", async (req, res) => { try { res.json(await loadMessages()); @@ -46,8 +92,7 @@ app.get("/api/messages", async (req, res) => { app.get("/api/messages/:id/raw", async (req, res) => { try { - const messages = await fetchSesMessages(); - const message = messages.find((candidate) => resolveMessageId(candidate) === req.params.id); + const message = await findSesMessageById(req.params.id); if (!message) { res.status(404).type("text/plain").send("Message not found"); @@ -61,951 +106,237 @@ app.get("/api/messages/:id/raw", 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 toMessageViewModel(message, index) { - const id = resolveMessageId(message, index); - +app.get("/api/messages/:id/attachments/:index", async (req, res) => { 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); + const attachmentIndex = Number.parseInt(req.params.index, 10); - 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) => ({ - filename: attachment.filename || "Unnamed attachment", - contentType: attachment.contentType || "application/octet-stream", - size: attachment.size || 0 - })), - preview: buildPreview(textContent, renderedHtml), - textContent, - renderedHtml, - hasHtml: Boolean(renderedHtml), - parseError: "" - }; + if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) { + res.status(400).type("text/plain").send("Attachment index must be a non-negative integer"); + return; + } + + const attachment = await loadMessageAttachment(req.params.id, attachmentIndex); + + if (!attachment) { + res.status(404).type("text/plain").send("Attachment not found"); + return; + } + + 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) { - 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 - }; + console.error("Error downloading attachment:", error); + res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`); } -} +}); -function resolveMessageId(message, index = 0) { - return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`; -} - -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 buildRenderedHtml(html) { - if (!html) { - return ""; - } - - const value = String(html); - const hasDocument = /]/i.test(value) || / - -
- - -LocalStack SES
-A faster local inbox for generated emails with live refresh, manual refresh, search, and raw MIME inspection.
-