diff --git a/_reference/localEmailViewer/README.md b/_reference/localEmailViewer/README.md index 6492c1176..019d6f946 100644 --- a/_reference/localEmailViewer/README.md +++ b/_reference/localEmailViewer/README.md @@ -3,6 +3,7 @@ This app connects to your Docker LocalStack endpoints and gives you a compact in - SES generated emails - CloudWatch log groups, streams, and recent events - Secrets Manager secrets and values +- S3 buckets and object previews ```shell npm start @@ -21,9 +22,14 @@ Features: - 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, and in-browser log search + 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, and copyable JSON/text values + 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 Optional environment variables: @@ -40,4 +46,9 @@ 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 f45eab624..64b72fbd7 100644 --- a/_reference/localEmailViewer/index.js +++ b/_reference/localEmailViewer/index.js @@ -7,6 +7,13 @@ import { FilterLogEventsCommand } from "@aws-sdk/client-cloudwatch-logs"; import { GetSecretValueCommand, ListSecretsCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { + GetObjectCommand, + HeadObjectCommand, + ListBucketsCommand, + ListObjectsV2Command, + S3Client +} from "@aws-sdk/client-s3"; import { simpleParser } from "mailparser"; const app = express(); @@ -22,6 +29,11 @@ const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW 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" @@ -36,6 +48,12 @@ const secretsManagerClient = new SecretsManagerClient({ endpoint: SECRETS_ENDPOINT, credentials: LOCALSTACK_CREDENTIALS }); +const s3Client = new S3Client({ + region: S3_REGION, + endpoint: S3_ENDPOINT, + credentials: LOCALSTACK_CREDENTIALS, + forcePathStyle: true +}); app.use((req, res, next) => { res.set("Cache-Control", "no-store"); @@ -57,13 +75,26 @@ app.get("/health", (req, res) => { endpoints: { ses: SES_ENDPOINT, cloudWatchLogs: CLOUDWATCH_ENDPOINT, - secretsManager: SECRETS_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()); @@ -236,6 +267,108 @@ app.get("/api/secrets/value", async (req, res) => { } }); +app.get("/api/s3/buckets", async (req, res) => { + try { + res.json(await loadS3Buckets()); + } catch (error) { + console.error("Error fetching S3 buckets:", error); + res.status(502).json({ + error: "Unable to fetch S3 buckets from LocalStack", + details: error.message, + endpoint: S3_ENDPOINT + }); + } +}); + +app.get("/api/s3/objects", async (req, res) => { + try { + const bucket = String(req.query.bucket || ""); + const prefix = String(req.query.prefix || ""); + + if (!bucket) { + res.status(400).json({ error: "Query parameter 'bucket' is required" }); + return; + } + + res.json(await loadS3Objects({ bucket, prefix })); + } catch (error) { + console.error("Error fetching S3 objects:", error); + res.status(502).json({ + error: "Unable to fetch S3 objects from LocalStack", + details: error.message, + endpoint: S3_ENDPOINT + }); + } +}); + +app.get("/api/s3/object", async (req, res) => { + try { + const bucket = String(req.query.bucket || ""); + const key = String(req.query.key || ""); + + if (!bucket || !key) { + res.status(400).json({ error: "Query parameters 'bucket' and 'key' are required" }); + return; + } + + res.json(await loadS3ObjectPreview({ bucket, key })); + } catch (error) { + if (error?.name === "NoSuchKey" || error?.name === "NotFound") { + res.status(404).json({ + error: "Object not found", + details: error.message, + endpoint: S3_ENDPOINT + }); + return; + } + + console.error("Error fetching S3 object preview:", error); + res.status(502).json({ + error: "Unable to fetch S3 object preview from LocalStack", + details: error.message, + endpoint: S3_ENDPOINT + }); + } +}); + +app.get("/api/s3/download", async (req, res) => { + try { + const bucket = String(req.query.bucket || ""); + const key = String(req.query.key || ""); + const inline = String(req.query.inline || "") === "1"; + + if (!bucket || !key) { + res.status(400).type("text/plain").send("Query parameters 'bucket' and 'key' are required"); + return; + } + + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: bucket, + Key: key + }) + ); + const content = Buffer.from(await response.Body.transformToByteArray()); + const filename = basenameFromKey(key); + + res.setHeader("Content-Type", response.ContentType || guessObjectContentType(key)); + res.setHeader( + "Content-Disposition", + inline ? buildInlineDisposition(filename) : buildAttachmentDisposition(filename) + ); + res.setHeader("Content-Length", String(content.length)); + res.send(content); + } catch (error) { + if (error?.name === "NoSuchKey" || error?.name === "NotFound") { + res.status(404).type("text/plain").send("Object not found"); + return; + } + + console.error("Error downloading S3 object:", error); + res.status(502).type("text/plain").send(`Unable to download S3 object: ${error.message}`); + } +}); + async function loadMessages() { const startedAt = Date.now(); const sesMessages = await fetchSesMessages(); @@ -496,6 +629,192 @@ async function loadSecretValue(secretId) { }; } +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; @@ -612,6 +931,116 @@ function buildAttachmentDisposition(filename) { 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 ""; @@ -715,6 +1144,9 @@ function getClientConfig() { 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 @@ -739,12 +1171,15 @@ function renderHtml() {

Inspector

-
- - - +
+ +
- +
+
+ Stack +
+
@@ -836,6 +1271,8 @@ function renderHtml() {
+ +
@@ -902,6 +1339,56 @@ function renderHtml() { + + @@ -911,7 +1398,7 @@ function renderHtml() { 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;--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);--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);--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);} + :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} @@ -929,11 +1416,21 @@ function renderStyles() { 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} - .workspaceTabs{display:flex;flex-wrap:wrap;gap:6px;padding:4px;border-radius:999px;background:rgba(31,41,51,.05)} - .workspaceTab,.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} - .workspaceTab{min-height:32px;padding:0 12px;background:transparent;color:var(--muted);font-weight:700} - .workspaceTab.active{background:#fff;border-color:rgba(31,41,51,.08);color:var(--ink)} + .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} @@ -956,6 +1453,7 @@ function renderStyles() { .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)} @@ -999,21 +1497,32 @@ function renderStyles() { .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} @@ -1030,10 +1539,12 @@ function renderStyles() { 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"] .workspaceTabs{background:rgba(148,163,184,.12)} - body[data-theme="dark"] .workspaceTab, + 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"] .workspaceTab.active, body[data-theme="dark"] .tab.active, body[data-theme="dark"] .ghost, body[data-theme="dark"] .mini, @@ -1044,7 +1555,6 @@ function renderStyles() { body[data-theme="dark"] .search::placeholder{color:#9fb0c2} body[data-theme="dark"] .ghost, body[data-theme="dark"] .mini, - body[data-theme="dark"] .workspaceTab.active, 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)} @@ -1052,6 +1562,8 @@ function renderStyles() { 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} @@ -1060,6 +1572,7 @@ function renderStyles() { 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)} @@ -1067,6 +1580,7 @@ function renderStyles() { 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, @@ -1084,12 +1598,11 @@ function renderStyles() { body[data-theme="dark"] .stat small, body[data-theme="dark"] .stat span, body[data-theme="dark"] .chip, - body[data-theme="dark"] .workspaceTab, 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{align-items:stretch}.stats{grid-template-columns:1fr}.heroTopRow{justify-content:stretch;flex-basis:100%}.workspaceTab,.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}} + @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}} `; } @@ -1108,17 +1621,21 @@ function clientApp(config) { 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() }; @@ -1155,6 +1672,8 @@ function clientApp(config) { 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, @@ -1180,15 +1699,47 @@ function clientApp(config) { 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 = { - workspaceTabs: document.getElementById("workspaceTabs"), 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"), @@ -1218,6 +1769,8 @@ function clientApp(config) { 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"), @@ -1252,7 +1805,30 @@ function clientApp(config) { secretsBanner: document.getElementById("secretsBanner"), secretsEmpty: document.getElementById("secretsEmpty"), secretsList: document.getElementById("secretsList"), - secretsContentPane: document.getElementById("secretsContentPane") + 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; @@ -1263,45 +1839,72 @@ function clientApp(config) { 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.workspaceTabs.addEventListener("click", async (event) => { - const button = event.target.closest("button[data-panel]"); + 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.panel); - }); - - el.themeToggle.addEventListener("click", () => { - applyTheme(appState.theme === "dark" ? "light" : "dark"); + await setPanel(button.dataset.healthPanel); }); el.refreshButton.addEventListener("click", () => refreshMessages("manual")); @@ -1424,6 +2027,17 @@ function clientApp(config) { 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(); @@ -1554,6 +2168,139 @@ function clientApp(config) { 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"); } }); @@ -1561,11 +2308,24 @@ function clientApp(config) { 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; } @@ -1612,6 +2372,11 @@ function clientApp(config) { return; } + if (appState.panel === "s3") { + refreshS3("keyboard"); + return; + } + refreshMessages("keyboard"); } }); @@ -1619,6 +2384,7 @@ function clientApp(config) { updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton); updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton); updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton); + updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton); } async function setPanel(panel) { @@ -1627,6 +2393,8 @@ function clientApp(config) { await ensureLogsReady(); } else if (panel === "secrets") { await ensureSecretsReady(); + } else if (panel === "s3") { + await ensureS3Ready(); } renderWorkspace(); @@ -1641,6 +2409,8 @@ function clientApp(config) { await ensureLogsReady(); } else if (panel === "secrets") { await ensureSecretsReady(); + } else if (panel === "s3") { + await ensureS3Ready(); } else if (!state.updatedAt && !state.loading) { await refreshMessages("panel"); } @@ -1648,21 +2418,19 @@ function clientApp(config) { 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.workspaceTabs.querySelectorAll("button[data-panel]").forEach((button) => { - const active = button.dataset.panel === appState.panel; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", active ? "true" : "false"); - }); + el.s3Panel.hidden = appState.panel !== "s3"; + renderHealthStrip(); } async function ensureLogsReady() { @@ -1687,6 +2455,19 @@ function clientApp(config) { 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; @@ -1868,6 +2649,10 @@ function clientApp(config) { logsState.loading = false; scheduleLogsRefresh(); renderLogsAll({ renderList: shouldRenderList }); + + if (shouldRenderList && logsState.tailNewest) { + scrollPaneToTop(el.logsContentPane); + } } } @@ -1917,6 +2702,132 @@ function clientApp(config) { } } + 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 @@ -1935,6 +2846,15 @@ function clientApp(config) { 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))); @@ -1967,6 +2887,19 @@ function clientApp(config) { 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) { @@ -2024,6 +2957,14 @@ function clientApp(config) { .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, @@ -2058,6 +2999,10 @@ function clientApp(config) { .toLowerCase(); } + function s3Haystack(object) { + return [object.bucket, object.key, object.storageClass, object.etag].filter(Boolean).join(" ").toLowerCase(); + } + function scheduleRefresh() { window.clearTimeout(state.timer); @@ -2088,6 +3033,16 @@ function clientApp(config) { 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(); @@ -2122,6 +3077,50 @@ function clientApp(config) { 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" : ""}`; @@ -2148,6 +3147,25 @@ function clientApp(config) { } 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() { @@ -2166,6 +3184,13 @@ function clientApp(config) { 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..."; @@ -2235,6 +3260,29 @@ function clientApp(config) { 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"; @@ -2343,6 +3391,42 @@ function clientApp(config) { 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"; @@ -2373,6 +3457,16 @@ function clientApp(config) { 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}` : ""; @@ -2443,7 +3537,39 @@ function clientApp(config) { 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 `
@@ -2451,6 +3577,7 @@ function clientApp(config) {
${escapeHtml(formatDateTime(event.timestamp))} ${escapeHtml(event.logStreamName || "Unknown stream")} + ${levelTag}
@@ -2459,7 +3586,7 @@ function clientApp(config) {

${renderLogPreviewContent(event)}

-
+
${renderLogBodyContent(event.message)}
@@ -2529,6 +3656,62 @@ function clientApp(config) { `; } + 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", () => { @@ -2566,6 +3749,25 @@ function clientApp(config) { }); } + 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", () => { @@ -2704,6 +3906,9 @@ function clientApp(config) { return `
Unable to load secret value: ${escapeHtml(valueState.error)}
`; } + const revealed = secretsState.revealedIds.has(secret.id); + const maskedHtml = escapeHtml(maskSecretValue(valueState.copyValue)); + return `
@@ -2720,11 +3925,55 @@ function clientApp(config) { }
+ + + +
-
${valueState.displayHtml}
+
${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} `; } @@ -2782,6 +4031,31 @@ function clientApp(config) { 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(); @@ -2870,6 +4144,10 @@ function clientApp(config) { 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; @@ -2967,6 +4245,67 @@ function clientApp(config) { } } + 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); @@ -3013,9 +4352,19 @@ function clientApp(config) { 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"].includes(storedPanel) ? storedPanel : "emails"; + return ["emails", "logs", "secrets", "s3"].includes(storedPanel) ? storedPanel : "emails"; } function getInitialTheme() { @@ -3072,7 +4421,9 @@ function clientApp(config) { auto: logsState.auto, interval: logsState.interval, windowMs: logsState.windowMs, - limit: logsState.limit + limit: logsState.limit, + wrapLines: logsState.wrapLines, + tailNewest: logsState.tailNewest }); } @@ -3084,6 +4435,33 @@ function clientApp(config) { }); } + 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); @@ -3238,6 +4616,74 @@ function clientApp(config) { 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); @@ -3329,4 +4775,5 @@ app.listen(PORT, () => { console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`); console.log(`Watching LocalStack CloudWatch Logs endpoint at ${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`); console.log(`Watching LocalStack Secrets Manager endpoint at ${SECRETS_ENDPOINT} (${SECRETS_REGION})`); + console.log(`Watching LocalStack S3 endpoint at ${S3_ENDPOINT} (${S3_REGION})`); }); diff --git a/_reference/localEmailViewer/package-lock.json b/_reference/localEmailViewer/package-lock.json index 2f00ef450..b01591261 100644 --- a/_reference/localEmailViewer/package-lock.json +++ b/_reference/localEmailViewer/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.1012.0", + "@aws-sdk/client-s3": "^3.1013.0", "@aws-sdk/client-secrets-manager": "^3.1013.0", "express": "^5.1.0", "mailparser": "^3.7.4", @@ -30,6 +31,69 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -208,6 +272,72 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1013.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1013.0.tgz", + "integrity": "sha512-vFdyRyRatF+xP9Fi+4alZkmzZadqOAM34Pm6SUZsYtumNrWkgMc/pFWITnsq6eltM8qcV/vcinQ1ZBXWm/PlKg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/credential-provider-node": "^3.972.23", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.2", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.22", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.23", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.9", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-retry": "^4.4.43", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.42", + "@smithy/util-defaults-mode-node": "^4.2.45", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-secrets-manager": { "version": "3.1013.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1013.0.tgz", @@ -282,6 +412,19 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-env": { "version": "3.972.20", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.20.tgz", @@ -440,6 +583,64 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.2.tgz", + "integrity": "sha512-4soN/N4R6ptdnHw7hXPVDZMIIL+vhN8rwtLdDyS0uD7ExhadtJzolTBIM5eKSkbw5uBEbIwtJc8HCG2NM6tN/g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.8", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", @@ -455,6 +656,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.972.8", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", @@ -485,6 +700,45 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.22.tgz", + "integrity": "sha512-dkUcRxF4rVpPbyHpxjCApGK6b7JpnSeo7tDoNakpRKmiLMCqgy4tlGBgeEYJnZgLrA4xc5jVKuXgvgqKqU18Kw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.22", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-user-agent": { "version": "3.972.23", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.23.tgz", @@ -569,6 +823,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.10.tgz", + "integrity": "sha512-yJSbFTedh1McfqXa9wZzjchqQ2puq5PI/qRz5kUjg2UXS5mO4MBYBbeXaZ2rp/h+ZbkcYEdo4Qsiah9psyoxrA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.22", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/token-providers": { "version": "3.1013.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1013.0.tgz", @@ -600,6 +871,18 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/util-endpoints": { "version": "3.996.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", @@ -714,6 +997,31 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/config-resolver": { "version": "4.4.13", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", @@ -854,6 +1162,21 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/hash-node": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", @@ -869,6 +1192,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/invalid-dependency": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", @@ -894,6 +1231,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/middleware-content-length": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", @@ -1340,6 +1691,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/uuid": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", diff --git a/_reference/localEmailViewer/package.json b/_reference/localEmailViewer/package.json index 67ea3fc6b..956794f4c 100644 --- a/_reference/localEmailViewer/package.json +++ b/_reference/localEmailViewer/package.json @@ -10,9 +10,10 @@ "keywords": [], "author": "", "license": "ISC", - "description": "LocalStack inspector for SES emails, CloudWatch logs, and Secrets Manager", + "description": "LocalStack inspector for SES emails, CloudWatch logs, Secrets Manager, and S3", "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.1012.0", + "@aws-sdk/client-s3": "^3.1013.0", "@aws-sdk/client-secrets-manager": "^3.1013.0", "express": "^5.1.0", "mailparser": "^3.7.4",