Files
bodyshop/_reference/localEmailViewer/index.js

4780 lines
166 KiB
JavaScript

import express from "express";
import fetch from "node-fetch";
import {
CloudWatchLogsClient,
DescribeLogGroupsCommand,
DescribeLogStreamsCommand,
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();
const PORT = Number(process.env.PORT || 3334);
const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses";
const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000);
const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000);
const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566";
const CLOUDWATCH_REGION = process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1";
const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development";
const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000);
const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200);
const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION;
const S3_ENDPOINT = process.env.S3_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
const S3_REGION = process.env.S3_VIEWER_REGION || CLOUDWATCH_REGION;
const S3_DEFAULT_BUCKET = process.env.S3_VIEWER_BUCKET || "";
const S3_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_PREVIEW_BYTES || 256 * 1024);
const S3_IMAGE_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_IMAGE_PREVIEW_BYTES || 1024 * 1024);
const LOCALSTACK_CREDENTIALS = {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test"
};
const cloudWatchLogsClient = new CloudWatchLogsClient({
region: CLOUDWATCH_REGION,
endpoint: CLOUDWATCH_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS
});
const secretsManagerClient = new SecretsManagerClient({
region: SECRETS_REGION,
endpoint: SECRETS_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS
});
const s3Client = new S3Client({
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS,
forcePathStyle: true
});
app.use((req, res, next) => {
res.set("Cache-Control", "no-store");
next();
});
app.get("/", (req, res) => {
res.type("html").send(renderHtml());
});
app.get("/app.js", (req, res) => {
res.type("application/javascript").send(`(${clientApp.toString()})(${JSON.stringify(getClientConfig())});`);
});
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());
} catch (error) {
console.error("Error fetching messages:", error);
res.status(502).json({
error: "Unable to fetch messages from LocalStack SES",
details: error.message,
endpoint: SES_ENDPOINT
});
}
});
app.get("/api/messages/:id/raw", async (req, res) => {
try {
const message = await findSesMessageById(req.params.id);
if (!message) {
res.status(404).type("text/plain").send("Message not found");
return;
}
res.type("text/plain").send(message.RawData || "");
} catch (error) {
console.error("Error fetching raw message:", error);
res.status(502).type("text/plain").send(`Unable to fetch raw message: ${error.message}`);
}
});
app.get("/api/messages/:id/attachments/:index", async (req, res) => {
try {
const attachmentIndex = Number.parseInt(req.params.index, 10);
if (!Number.isInteger(attachmentIndex) || attachmentIndex < 0) {
res.status(400).type("text/plain").send("Attachment index must be a non-negative integer");
return;
}
const parsed = await parseSesMessageById(req.params.id);
if (!parsed) {
res.status(404).type("text/plain").send("Message not found");
return;
}
const attachment = parsed.attachments?.[attachmentIndex];
if (!attachment) {
res.status(404).type("text/plain").send("Attachment not found");
return;
}
const filename = resolveAttachmentFilename(attachment, attachmentIndex);
const content = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || "");
res.setHeader("Content-Type", attachment.contentType || "application/octet-stream");
res.setHeader("Content-Disposition", buildAttachmentDisposition(filename));
res.setHeader("Content-Length", String(content.length));
res.send(content);
} catch (error) {
console.error("Error downloading attachment:", error);
res.status(502).type("text/plain").send(`Unable to download attachment: ${error.message}`);
}
});
app.get("/api/logs/groups", async (req, res) => {
try {
const groups = await loadLogGroups();
res.json({
endpoint: CLOUDWATCH_ENDPOINT,
region: CLOUDWATCH_REGION,
defaultGroup: CLOUDWATCH_DEFAULT_GROUP,
groups
});
} catch (error) {
console.error("Error fetching log groups:", error);
res.status(502).json({
error: "Unable to fetch CloudWatch log groups from LocalStack",
details: error.message,
endpoint: CLOUDWATCH_ENDPOINT
});
}
});
app.get("/api/logs/streams", async (req, res) => {
try {
const logGroupName = String(req.query.group || "");
if (!logGroupName) {
res.status(400).json({ error: "Query parameter 'group' is required" });
return;
}
res.json({
logGroupName,
streams: await loadLogStreams(logGroupName)
});
} catch (error) {
console.error("Error fetching log streams:", error);
res.status(502).json({
error: "Unable to fetch CloudWatch log streams from LocalStack",
details: error.message,
endpoint: CLOUDWATCH_ENDPOINT
});
}
});
app.get("/api/logs/events", async (req, res) => {
try {
const logGroupName = String(req.query.group || "");
const logStreamName = String(req.query.stream || "");
const windowMs = clampNumber(req.query.windowMs, CLOUDWATCH_DEFAULT_WINDOW_MS, 60 * 1000, 24 * 60 * 60 * 1000);
const limit = clampNumber(req.query.limit, CLOUDWATCH_DEFAULT_LIMIT, 25, 500);
if (!logGroupName) {
res.status(400).json({ error: "Query parameter 'group' is required" });
return;
}
res.json(await loadLogEvents({ logGroupName, logStreamName, windowMs, limit }));
} catch (error) {
console.error("Error fetching log events:", error);
res.status(502).json({
error: "Unable to fetch CloudWatch log events from LocalStack",
details: error.message,
endpoint: CLOUDWATCH_ENDPOINT
});
}
});
app.get("/api/secrets", async (req, res) => {
try {
res.json(await loadSecrets());
} catch (error) {
console.error("Error fetching secrets:", error);
res.status(502).json({
error: "Unable to fetch Secrets Manager secrets from LocalStack",
details: error.message,
endpoint: SECRETS_ENDPOINT
});
}
});
app.get("/api/secrets/value", async (req, res) => {
try {
const secretId = String(req.query.id || "");
if (!secretId) {
res.status(400).json({ error: "Query parameter 'id' is required" });
return;
}
res.json(await loadSecretValue(secretId));
} catch (error) {
if (error?.name === "ResourceNotFoundException") {
res.status(404).json({
error: "Secret not found",
details: error.message,
endpoint: SECRETS_ENDPOINT
});
return;
}
console.error("Error fetching secret value:", error);
res.status(502).json({
error: "Unable to fetch Secrets Manager value from LocalStack",
details: error.message,
endpoint: SECRETS_ENDPOINT
});
}
});
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();
const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index)));
messages.sort((left, right) => {
if ((right.timestampMs || 0) !== (left.timestampMs || 0)) {
return (right.timestampMs || 0) - (left.timestampMs || 0);
}
return right.index - left.index;
});
return {
endpoint: SES_ENDPOINT,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalMessages: messages.length,
parseErrors: messages.filter((message) => Boolean(message.parseError)).length,
latestMessageTimestamp: messages[0]?.timestamp || "",
messages
};
}
async function fetchSesMessages() {
const response = await fetch(SES_ENDPOINT, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) {
throw new Error(`SES endpoint responded with ${response.status}`);
}
const data = await response.json();
return Array.isArray(data.messages) ? data.messages : [];
}
async function loadLogGroups() {
const groups = [];
let nextToken;
let pageCount = 0;
do {
const response = await cloudWatchLogsClient.send(
new DescribeLogGroupsCommand({
nextToken,
limit: 50
})
);
groups.push(
...(response.logGroups || []).map((group) => ({
name: group.logGroupName || "",
arn: group.arn || "",
storedBytes: group.storedBytes || 0,
retentionInDays: group.retentionInDays || 0,
creationTime: group.creationTime || 0
}))
);
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && pageCount < 10);
return groups.sort((left, right) => left.name.localeCompare(right.name));
}
async function loadLogStreams(logGroupName) {
const streams = [];
let nextToken;
let pageCount = 0;
do {
const response = await cloudWatchLogsClient.send(
new DescribeLogStreamsCommand({
logGroupName,
descending: true,
orderBy: "LastEventTime",
nextToken,
limit: 50
})
);
streams.push(
...(response.logStreams || []).map((stream) => ({
name: stream.logStreamName || "",
arn: stream.arn || "",
lastEventTimestamp: stream.lastEventTimestamp || 0,
lastIngestionTime: stream.lastIngestionTime || 0,
storedBytes: stream.storedBytes || 0
}))
);
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && pageCount < 6 && streams.length < 250);
return streams;
}
async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) {
const startedAt = Date.now();
const eventMap = new Map();
const startTime = Date.now() - windowMs;
let nextToken;
let previousToken = "";
let pageCount = 0;
let searchedLogStreams = 0;
do {
const response = await cloudWatchLogsClient.send(
new FilterLogEventsCommand({
logGroupName,
logStreamNames: logStreamName ? [logStreamName] : undefined,
startTime,
endTime: Date.now(),
limit,
nextToken
})
);
for (const event of response.events || []) {
const id =
event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`;
if (!eventMap.has(id)) {
const message = String(event.message || "").trim();
eventMap.set(id, {
id,
timestamp: event.timestamp || 0,
ingestionTime: event.ingestionTime || 0,
logStreamName: event.logStreamName || "",
message,
preview: buildLogPreview(message)
});
}
}
searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length);
previousToken = nextToken || "";
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit);
const events = [...eventMap.values()]
.sort((left, right) => {
if ((right.timestamp || 0) !== (left.timestamp || 0)) {
return (right.timestamp || 0) - (left.timestamp || 0);
}
return left.logStreamName.localeCompare(right.logStreamName);
})
.slice(0, limit);
return {
endpoint: CLOUDWATCH_ENDPOINT,
region: CLOUDWATCH_REGION,
logGroupName,
logStreamName,
fetchDurationMs: Date.now() - startedAt,
latestTimestamp: events[0]?.timestamp || 0,
searchedLogStreams,
totalEvents: events.length,
events
};
}
async function loadSecrets() {
const startedAt = Date.now();
const secrets = [];
let nextToken;
let pageCount = 0;
do {
const response = await secretsManagerClient.send(
new ListSecretsCommand({
NextToken: nextToken,
MaxResults: 50
})
);
secrets.push(
...(response.SecretList || []).map((secret, index) => ({
id: secret.ARN || secret.Name || `secret-${index}`,
name: secret.Name || "Unnamed secret",
arn: secret.ARN || "",
description: secret.Description || "",
createdDate: normalizeTimestamp(secret.CreatedDate),
lastChangedDate: normalizeTimestamp(secret.LastChangedDate),
lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate),
deletedDate: normalizeTimestamp(secret.DeletedDate),
primaryRegion: secret.PrimaryRegion || "",
owningService: secret.OwningService || "",
rotationEnabled: Boolean(secret.RotationEnabled),
versionCount: Object.keys(secret.SecretVersionsToStages || {}).length,
tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0,
tags: (secret.Tags || [])
.map((tag) => ({
key: tag.Key || "",
value: tag.Value || ""
}))
.filter((tag) => tag.key || tag.value)
}))
);
nextToken = response.NextToken;
pageCount += 1;
} while (nextToken && pageCount < 10 && secrets.length < 500);
secrets.sort((left, right) => {
const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0;
const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0;
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.name.localeCompare(right.name);
});
return {
endpoint: SECRETS_ENDPOINT,
region: SECRETS_REGION,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalSecrets: secrets.length,
latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "",
secrets
};
}
async function loadSecretValue(secretId) {
const startedAt = Date.now();
const response = await secretsManagerClient.send(
new GetSecretValueCommand({
SecretId: secretId
})
);
const secretBinary = response.SecretBinary
? typeof response.SecretBinary === "string"
? response.SecretBinary
: Buffer.from(response.SecretBinary).toString("base64")
: "";
return {
endpoint: SECRETS_ENDPOINT,
region: SECRETS_REGION,
fetchDurationMs: Date.now() - startedAt,
id: secretId,
name: response.Name || "",
arn: response.ARN || "",
versionId: response.VersionId || "",
versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [],
createdDate: normalizeTimestamp(response.CreatedDate),
secretString: typeof response.SecretString === "string" ? response.SecretString : "",
secretBinary
};
}
async function loadS3Buckets() {
const startedAt = Date.now();
const response = await s3Client.send(new ListBucketsCommand({}));
const buckets = (response.Buckets || [])
.map((bucket) => ({
name: bucket.Name || "",
creationDate: normalizeTimestamp(bucket.CreationDate)
}))
.filter((bucket) => bucket.name)
.sort((left, right) => left.name.localeCompare(right.name));
return {
endpoint: S3_ENDPOINT,
region: S3_REGION,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalBuckets: buckets.length,
buckets
};
}
async function loadS3Objects({ bucket, prefix }) {
const startedAt = Date.now();
const objects = [];
let continuationToken;
let pageCount = 0;
do {
const response = await s3Client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix || undefined,
ContinuationToken: continuationToken,
MaxKeys: 200
})
);
objects.push(
...(response.Contents || []).map((object, index) => ({
id: `${bucket}::${object.Key || index}`,
bucket,
key: object.Key || "",
size: object.Size || 0,
lastModified: normalizeTimestamp(object.LastModified),
etag: String(object.ETag || "").replace(/^"|"$/g, ""),
storageClass: object.StorageClass || "STANDARD"
}))
);
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
pageCount += 1;
} while (continuationToken && pageCount < 10 && objects.length < 1000);
objects.sort((left, right) => {
const leftTime = Date.parse(left.lastModified || 0) || 0;
const rightTime = Date.parse(right.lastModified || 0) || 0;
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.key.localeCompare(right.key);
});
return {
endpoint: S3_ENDPOINT,
region: S3_REGION,
bucket,
prefix,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalObjects: objects.length,
latestTimestamp: objects[0]?.lastModified || "",
objects
};
}
async function loadS3ObjectPreview({ bucket, key }) {
const startedAt = Date.now();
const head = await s3Client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key
})
);
const contentType = head.ContentType || guessObjectContentType(key);
const contentLength = Number(head.ContentLength || 0);
const previewType = resolveS3PreviewType(contentType, key);
const result = {
endpoint: S3_ENDPOINT,
region: S3_REGION,
bucket,
key,
fetchDurationMs: 0,
contentType,
contentLength,
etag: String(head.ETag || "").replace(/^"|"$/g, ""),
lastModified: normalizeTimestamp(head.LastModified),
metadata: head.Metadata || {},
previewType,
previewText: "",
imageDataUrl: "",
truncated: false
};
const shouldLoadTextPreview = previewType === "json" || previewType === "text" || previewType === "html";
const shouldLoadImagePreview =
previewType === "image" && contentLength > 0 && contentLength <= S3_IMAGE_PREVIEW_MAX_BYTES;
if ((shouldLoadTextPreview || shouldLoadImagePreview) && contentLength > 0) {
const previewBytes = Math.max(1, Math.min(contentLength || S3_PREVIEW_MAX_BYTES, S3_PREVIEW_MAX_BYTES));
const response = await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: `bytes=0-${previewBytes - 1}`
})
);
const content = Buffer.from(await response.Body.transformToByteArray());
result.truncated = contentLength > content.length;
if (shouldLoadImagePreview) {
result.imageDataUrl = `data:${contentType};base64,${content.toString("base64")}`;
} else {
result.previewText = content.toString("utf8");
}
}
result.fetchDurationMs = Date.now() - startedAt;
return result;
}
async function loadServiceHealthSummary() {
const startedAt = Date.now();
const [sesResult, logsResult, secretsResult, s3Result] = await Promise.allSettled([
fetchSesMessages(),
loadLogGroups(),
loadSecrets(),
loadS3Buckets()
]);
return {
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
services: {
emails: summarizeHealthResult({
icon: "✉️",
panel: "emails",
label: "SES Emails",
result: sesResult,
count: sesResult.status === "fulfilled" ? sesResult.value.length : 0,
detail: SES_ENDPOINT,
noun: "email"
}),
logs: summarizeHealthResult({
icon: "📜",
panel: "logs",
label: "CloudWatch Logs",
result: logsResult,
count: logsResult.status === "fulfilled" ? logsResult.value.length : 0,
detail: `${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`,
noun: "group"
}),
secrets: summarizeHealthResult({
icon: "🔐",
panel: "secrets",
label: "Secrets Manager",
result: secretsResult,
count: secretsResult.status === "fulfilled" ? secretsResult.value.totalSecrets : 0,
detail: `${SECRETS_ENDPOINT} (${SECRETS_REGION})`,
noun: "secret"
}),
s3: summarizeHealthResult({
icon: "🪣",
panel: "s3",
label: "S3 Explorer",
result: s3Result,
count: s3Result.status === "fulfilled" ? s3Result.value.totalBuckets : 0,
detail: `${S3_ENDPOINT} (${S3_REGION})`,
noun: "bucket"
})
}
};
}
async function findSesMessageById(id) {
const messages = await fetchSesMessages();
return messages.find((message, index) => resolveMessageId(message, index) === id) || null;
}
async function parseSesMessageById(id) {
const message = await findSesMessageById(id);
if (!message) {
return null;
}
return simpleParser(message.RawData || "");
}
async function toMessageViewModel(message, index) {
const id = resolveMessageId(message, index);
try {
const parsed = await simpleParser(message.RawData || "");
const textContent = normalizeText(parsed.text || "");
const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || "");
const timestamp = normalizeTimestamp(message.Timestamp || parsed.date);
return {
id,
index,
from: formatAddressList(parsed.from) || message.Source || "Unknown sender",
to: formatAddressList(parsed.to) || "No To Address",
replyTo: formatAddressList(parsed.replyTo),
subject: parsed.subject || "No Subject",
region: message.Region || "",
timestamp,
timestampMs: timestamp ? Date.parse(timestamp) : 0,
messageId: parsed.messageId || "",
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
attachmentCount: parsed.attachments.length,
attachments: parsed.attachments.map((attachment, attachmentIndex) => ({
index: attachmentIndex,
filename: resolveAttachmentFilename(attachment, attachmentIndex),
contentType: attachment.contentType || "application/octet-stream",
size: attachment.size || 0
})),
preview: buildPreview(textContent, renderedHtml),
textContent,
renderedHtml,
hasHtml: Boolean(renderedHtml),
parseError: ""
};
} catch (error) {
return {
id,
index,
from: message.Source || "Unknown sender",
to: "Unknown recipient",
replyTo: "",
subject: "Unable to parse message",
region: message.Region || "",
timestamp: normalizeTimestamp(message.Timestamp),
timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0,
messageId: "",
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
attachmentCount: 0,
attachments: [],
preview: "This message could not be parsed. Open the raw view to inspect the MIME source.",
textContent: "",
renderedHtml: "",
hasHtml: false,
parseError: error.message
};
}
}
function resolveMessageId(message, index = 0) {
return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`;
}
function resolveAttachmentFilename(attachment, index = 0) {
if (attachment?.filename) {
return attachment.filename;
}
return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`;
}
function attachmentExtension(contentType) {
const normalized = String(contentType || "")
.split(";")[0]
.trim()
.toLowerCase();
return (
{
"application/json": ".json",
"application/pdf": ".pdf",
"application/zip": ".zip",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"text/calendar": ".ics",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt"
}[normalized] || ""
);
}
function buildAttachmentDisposition(filename) {
const fallback = String(filename || "attachment")
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "_");
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`;
}
function buildInlineDisposition(filename) {
const fallback = String(filename || "file")
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "_");
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "file")}`;
}
function basenameFromKey(key) {
const value = String(key || "");
const parts = value.split("/").filter(Boolean);
return parts[parts.length - 1] || "file";
}
function guessObjectContentType(key) {
const normalizedKey = String(key || "").toLowerCase();
if (normalizedKey.endsWith(".json")) {
return "application/json";
}
if (normalizedKey.endsWith(".csv")) {
return "text/csv";
}
if (normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
return "text/html";
}
if (normalizedKey.endsWith(".txt") || normalizedKey.endsWith(".log") || normalizedKey.endsWith(".md")) {
return "text/plain";
}
if (normalizedKey.endsWith(".png")) {
return "image/png";
}
if (normalizedKey.endsWith(".jpg") || normalizedKey.endsWith(".jpeg")) {
return "image/jpeg";
}
if (normalizedKey.endsWith(".gif")) {
return "image/gif";
}
if (normalizedKey.endsWith(".webp")) {
return "image/webp";
}
if (normalizedKey.endsWith(".svg")) {
return "image/svg+xml";
}
if (normalizedKey.endsWith(".pdf")) {
return "application/pdf";
}
return "application/octet-stream";
}
function resolveS3PreviewType(contentType, key) {
const normalizedType = String(contentType || "").toLowerCase();
const normalizedKey = String(key || "").toLowerCase();
if (normalizedType.includes("json") || normalizedKey.endsWith(".json")) {
return "json";
}
if (normalizedType.startsWith("image/")) {
return "image";
}
if (normalizedType.includes("html") || normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
return "html";
}
if (
normalizedType.startsWith("text/") ||
[".txt", ".log", ".csv", ".xml", ".yml", ".yaml", ".md"].some((extension) => normalizedKey.endsWith(extension))
) {
return "text";
}
return "binary";
}
function summarizeHealthResult({ icon, panel, label, result, count, detail, noun }) {
if (result.status === "fulfilled") {
return {
ok: true,
icon,
panel,
label,
count,
summary: `${count} ${noun}${count === 1 ? "" : "s"}`,
detail
};
}
return {
ok: false,
icon,
panel,
label,
count: 0,
summary: "Needs attention",
detail: result.reason?.message || detail
};
}
function normalizeTimestamp(value) {
if (!value) {
return "";
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
}
function normalizeText(value) {
return String(value || "")
.replace(/\r\n/g, "\n")
.trim();
}
function buildPreview(textContent, renderedHtml) {
const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim();
if (!source) {
return "No message preview available.";
}
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
}
function buildLogPreview(message) {
const source = String(message || "")
.replace(/\s+/g, " ")
.trim();
if (!source) {
return "No log preview available.";
}
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
}
function clampNumber(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.min(Math.max(parsed, min), max);
}
function buildRenderedHtml(html) {
if (!html) {
return "";
}
const value = String(html);
const hasDocument = /<html[\s>]/i.test(value) || /<!doctype/i.test(value);
if (hasDocument) {
return value;
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base target="_blank">
<style>body{margin:0;padding:16px;font-family:Arial,sans-serif;background:#fff;}</style>
</head>
<body>${value}</body>
</html>`;
}
function stripTags(value) {
return String(value || "")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ");
}
function formatAddressList(addresses) {
if (!addresses?.value?.length) {
return "";
}
return addresses.value
.map(({ name, address }) => {
if (name && address) {
return `${name} <${address}>`;
}
return address || name || "";
})
.filter(Boolean)
.join(", ");
}
function getClientConfig() {
return {
defaultRefreshMs: DEFAULT_REFRESH_MS,
endpoint: SES_ENDPOINT,
cloudWatchEndpoint: CLOUDWATCH_ENDPOINT,
cloudWatchRegion: CLOUDWATCH_REGION,
secretsEndpoint: SECRETS_ENDPOINT,
secretsRegion: SECRETS_REGION,
s3Endpoint: S3_ENDPOINT,
s3Region: S3_REGION,
defaultS3Bucket: S3_DEFAULT_BUCKET,
defaultLogGroup: CLOUDWATCH_DEFAULT_GROUP,
defaultLogWindowMs: CLOUDWATCH_DEFAULT_WINDOW_MS,
defaultLogLimit: CLOUDWATCH_DEFAULT_LIMIT
};
}
function renderHtml() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalStack Inspector</title>
<style>${renderStyles()}</style>
</head>
<body>
<div class="page">
<header class="hero">
<div class="heroShell">
<div class="heroIdentity">
<p class="eyebrow">LocalStack Toolbox</p>
<h1>Inspector</h1>
</div>
<div class="heroTopRow">
<div class="heroActions">
<button id="themeToggle" class="ghost themeToggle" type="button" aria-pressed="false">☀️ Light theme</button>
<button id="resetStateButton" class="ghost" type="button">🧹 Reset saved state</button>
</div>
</div>
<div class="heroStatusRow">
<span class="heroStatusLabel">Stack</span>
<div id="healthStrip" class="healthStrip" aria-live="polite"></div>
<button id="healthRefreshButton" class="mini healthRefreshButton" type="button" title="Refresh service health" aria-label="Refresh service health">🩺</button>
</div>
</div>
</header>
<section id="emailsPanel" class="workspacePanel">
<section class="toolControls">
<div class="row">
<button id="refreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="autoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="intervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="statusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<input id="searchInput" class="search" type="search" placeholder="Search subject, sender, preview..." autocomplete="off">
<button id="clearSearchButton" class="ghost" type="button">Clear</button>
<button id="expandAllButton" class="ghost" type="button">Open all</button>
<button id="collapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Total</span><strong id="totalStat">0</strong><small id="visibleStat">0 visible</small></article>
<article class="stat"><span>New</span><strong id="newStat">0</strong><small>New since last refresh</small></article>
<article class="stat"><span>Newest</span><strong id="newestStat" class="small">No messages</strong><small id="updatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="fetchStat" class="small">Idle</strong><small id="fetchDetail">Endpoint: ${escapeHtml(SES_ENDPOINT)}</small></article>
</section>
<div id="emailsContentPane" class="contentPane">
<div class="contentStack">
<div id="banner" class="banner" hidden></div>
<div id="empty" class="empty" hidden></div>
<section id="list" class="list" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="scrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
<section id="logsPanel" class="workspacePanel" hidden>
<section class="toolControls">
<div class="row">
<button id="logsRefreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="logsAutoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="logsIntervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="logsStatusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<label class="chip">Group
<select id="logsGroupSelect"></select>
</label>
<label class="chip">Stream
<select id="logsStreamSelect"></select>
</label>
<label class="chip">Window
<select id="logsWindowSelect">
<option value="300000">5m</option>
<option value="900000" selected>15m</option>
<option value="3600000">1h</option>
<option value="21600000">6h</option>
<option value="86400000">24h</option>
</select>
</label>
<label class="chip">Limit
<select id="logsLimitSelect">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="300">300</option>
<option value="500">500</option>
</select>
</label>
</div>
<div class="row">
<input id="logsSearchInput" class="search" type="search" placeholder="Search stream name or log content..." autocomplete="off">
<button id="logsClearSearchButton" class="ghost" type="button">Clear</button>
<label class="chip"><input id="logsWrapToggle" type="checkbox" checked> Wrap lines</label>
<label class="chip"><input id="logsTailToggle" type="checkbox"> Tail newest</label>
<button id="logsExpandAllButton" class="ghost" type="button">Open all</button>
<button id="logsCollapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Events</span><strong id="logsTotalStat">0</strong><small id="logsVisibleStat">0 visible</small></article>
<article class="stat"><span>Streams</span><strong id="logsStreamsStat">0</strong><small>Streams in selected group</small></article>
<article class="stat"><span>Latest</span><strong id="logsNewestStat" class="small">No events</strong><small id="logsUpdatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="logsFetchStat" class="small">Idle</strong><small id="logsFetchDetail">Endpoint: ${escapeHtml(CLOUDWATCH_ENDPOINT)} (${escapeHtml(CLOUDWATCH_REGION)})</small></article>
</section>
<div id="logsContentPane" class="contentPane">
<div class="contentStack">
<div id="logsBanner" class="banner" hidden></div>
<div id="logsEmpty" class="empty" hidden></div>
<section id="logsList" class="logList" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="logsScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
<section id="secretsPanel" class="workspacePanel" hidden>
<section class="toolControls">
<div class="row">
<button id="secretsRefreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="secretsAutoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="secretsIntervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="secretsStatusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<input id="secretsSearchInput" class="search" type="search" placeholder="Search secret name, description, service, tags..." autocomplete="off">
<button id="secretsClearSearchButton" class="ghost" type="button">Clear</button>
<button id="secretsExpandAllButton" class="ghost" type="button">Open all</button>
<button id="secretsCollapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Secrets</span><strong id="secretsTotalStat">0</strong><small id="secretsVisibleStat">0 visible</small></article>
<article class="stat"><span>Loaded</span><strong id="secretsLoadedStat">0</strong><small>Values loaded this session</small></article>
<article class="stat"><span>Latest</span><strong id="secretsNewestStat" class="small">No secrets</strong><small id="secretsUpdatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="secretsFetchStat" class="small">Idle</strong><small id="secretsFetchDetail">Endpoint: ${escapeHtml(SECRETS_ENDPOINT)} (${escapeHtml(SECRETS_REGION)})</small></article>
</section>
<div id="secretsContentPane" class="contentPane">
<div class="contentStack">
<div id="secretsBanner" class="banner" hidden></div>
<div id="secretsEmpty" class="empty" hidden></div>
<section id="secretsList" class="list" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="secretsScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
<section id="s3Panel" class="workspacePanel" hidden>
<section class="toolControls">
<div class="row">
<button id="s3RefreshButton" class="primary" type="button">🔄 Refresh</button>
<label class="chip"><input id="s3AutoToggle" type="checkbox" checked> Live refresh</label>
<label class="chip">Every
<select id="s3IntervalSelect">
<option value="5000">5s</option>
<option value="10000" selected>10s</option>
<option value="15000">15s</option>
<option value="30000">30s</option>
<option value="60000">60s</option>
</select>
</label>
<span id="s3StatusChip" class="status">Waiting for first refresh...</span>
</div>
<div class="row">
<label class="chip">Bucket
<select id="s3BucketSelect"></select>
</label>
<input id="s3PrefixInput" class="search searchCompact" type="search" placeholder="Prefix filter (optional)" autocomplete="off">
<button id="s3ApplyPrefixButton" class="ghost" type="button">Apply prefix</button>
</div>
<div class="row">
<input id="s3SearchInput" class="search" type="search" placeholder="Search object key, storage class, or etag..." autocomplete="off">
<button id="s3ClearSearchButton" class="ghost" type="button">Clear</button>
<button id="s3ExpandAllButton" class="ghost" type="button">Open all</button>
<button id="s3CollapseAllButton" class="ghost" type="button">Close all</button>
</div>
</section>
<section class="stats">
<article class="stat"><span>Objects</span><strong id="s3TotalStat">0</strong><small id="s3VisibleStat">0 visible</small></article>
<article class="stat"><span>Buckets</span><strong id="s3BucketsStat">0</strong><small>Available in LocalStack</small></article>
<article class="stat"><span>Latest</span><strong id="s3NewestStat" class="small">No objects</strong><small id="s3UpdatedStat">Not refreshed yet</small></article>
<article class="stat"><span>Fetch</span><strong id="s3FetchStat" class="small">Idle</strong><small id="s3FetchDetail">Endpoint: ${escapeHtml(S3_ENDPOINT)} (${escapeHtml(S3_REGION)})</small></article>
</section>
<div id="s3ContentPane" class="contentPane">
<div class="contentStack">
<div id="s3Banner" class="banner" hidden></div>
<div id="s3Empty" class="empty" hidden></div>
<section id="s3List" class="list" aria-live="polite"></section>
<div class="paneTopWrap">
<button id="s3ScrollToTopButton" class="paneTopButton" type="button" title="Scroll to top" aria-label="Scroll to top">&#8593;</button>
</div>
</div>
</div>
</section>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>`;
}
function renderStyles() {
return `
:root{--panel:rgba(255,255,255,.82);--panel-strong:#fff;--card-shell:linear-gradient(180deg,rgba(255,246,236,.98),rgba(255,252,247,.99));--card-body:#fffdf9;--log-shell:linear-gradient(180deg,rgba(239,246,255,.98),rgba(248,251,255,.99));--log-body:#f8fbff;--secret-shell:linear-gradient(180deg,rgba(239,251,246,.98),rgba(247,253,249,.99));--secret-body:#f6fcf8;--bucket-shell:linear-gradient(180deg,rgba(255,249,232,.98),rgba(255,252,243,.99));--bucket-body:#fffcf2;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--secret-line:rgba(31,143,101,.2);--bucket-line:rgba(181,137,37,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--secret:#1f8f65;--secret-soft:rgba(31,143,101,.1);--bucket:#9d6b00;--bucket-soft:rgba(181,137,37,.12);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);--secret-shadow:0 16px 32px rgba(31,143,101,.12);--bucket-shadow:0 16px 32px rgba(181,137,37,.12);}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden}
body{color-scheme:light;background:radial-gradient(circle at top left,rgba(207,109,60,.18),transparent 28%),radial-gradient(circle at top right,rgba(31,143,101,.12),transparent 24%),linear-gradient(180deg,#f8f5ef,#efe7da);color:var(--ink);font:15px/1.45 "Aptos","Segoe UI Variable Display","Segoe UI",system-ui,sans-serif;transition:background-color .18s ease,color .18s ease}
button,input,select,textarea{font:inherit}
button{cursor:pointer}
.page{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;max-width:1360px;height:100vh;height:100dvh;margin:0 auto;padding:14px;overflow:hidden}
.hero{display:block;margin-bottom:0}
.heroShell,.toolControls,.stat{background:var(--panel);backdrop-filter:blur(14px);border:1px solid var(--line);box-shadow:var(--shadow)}
.card{background:var(--card-shell);border:1px solid var(--card-line);box-shadow:var(--card-shadow)}
.heroShell,.toolControls{border-radius:18px}
.heroShell{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px}
.toolControls{padding:12px}
.heroIdentity{display:grid;gap:3px;min-width:0}
.eyebrow{margin:0 0 4px;color:var(--accent);font-size:.72rem;font-weight:700;letter-spacing:.16em;text-transform:uppercase}
h1{margin:0;font-size:clamp(1.8rem,3.6vw,2.85rem);line-height:.96;letter-spacing:-.05em}
.lede{margin:8px 0 0;max-width:54ch;color:var(--muted);font-size:.92rem}
.heroTopRow{display:flex;flex:1 1 360px;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
.heroActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
.heroStatusRow{display:flex;flex:1 1 100%;flex-wrap:wrap;gap:8px;align-items:center}
.heroStatusLabel{color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
.helper{margin:0;color:var(--muted);font-size:.89rem}
.healthStrip{display:flex;flex:1 1 520px;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
.healthBadge{display:inline-flex;align-items:center;gap:8px;min-height:30px;max-width:100%;padding:0 10px;border-radius:999px;border:1px solid rgba(31,41,51,.1);background:rgba(255,255,255,.78);box-shadow:0 8px 18px rgba(15,23,42,.06);text-align:left;transition:transform .12s ease,background-color .12s ease,border-color .12s ease,box-shadow .12s ease}
.healthBadgeName{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:800;white-space:nowrap}
.healthBadgeSummary{min-width:0;overflow:hidden;color:var(--muted);font-size:.78rem;font-weight:700;text-overflow:ellipsis;white-space:nowrap}
.healthBadge.ok{border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.1)}
.healthBadge.bad{border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.1)}
.healthBadge.warn{border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.1)}
.healthBadge.active{border-color:rgba(207,109,60,.28);background:rgba(207,109,60,.16);box-shadow:0 10px 24px rgba(207,109,60,.12)}
.healthBadge.active .healthBadgeName,.healthBadge.active .healthBadgeSummary{color:var(--ink)}
.healthRefreshButton{flex:0 0 auto;padding:0 10px}
.primary,.ghost,.mini,.tab{display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
.themeToggle{white-space:nowrap}
.workspacePanel{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:6px;min-height:0}
.workspacePanel[hidden]{display:none}
.toolControls{display:grid;gap:8px}
.contentPane{height:100%;min-height:0;overflow:auto;scroll-behavior:smooth;padding-right:4px}
.contentStack{display:grid;gap:8px;min-width:100%;padding-bottom:18px}
.paneTopWrap{display:flex;justify-content:flex-end;position:sticky;bottom:14px;pointer-events:none;padding-right:10px}
.paneTopButton{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;border-radius:999px;border:1px solid rgba(255,255,255,.32);background:rgba(31,41,51,.42);color:#fff;font-size:1.1rem;line-height:1;backdrop-filter:blur(12px);box-shadow:0 10px 24px rgba(31,41,51,.18);opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none;transition:opacity .16s ease,transform .16s ease,background-color .12s ease;z-index:6}
.paneTopButton.visible{opacity:.78;transform:translateY(0);visibility:visible;pointer-events:auto}
.paneTopButton.visible:hover{opacity:1;background:rgba(31,41,51,.62);transform:translateY(-1px)}
.row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.primary,.ghost{min-height:34px;padding:0 12px;font-weight:700}
.mini,.tab{min-height:28px;padding:0 10px;font-weight:600}
.primary{background:var(--accent);color:#fff7f2}
.ghost,.mini{background:rgba(255,255,255,.76);border-color:var(--line);color:var(--ink)}
.tab{background:transparent;color:var(--muted)}
.tab.active{background:#fff;border-color:rgba(207,109,60,.18);color:var(--ink)}
.primary:hover,.ghost:hover,.mini:hover,.tab:hover{transform:translateY(-1px)}
.chip{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:0 10px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);font-weight:600;font-size:.88rem}
.chip input{margin:0;accent-color:var(--accent)}
.chip select{border:none;background:transparent;outline:none;color:var(--ink)}
.search{flex:1 1 260px;min-height:36px;padding:0 12px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.82);color:var(--ink);outline:none}
.searchCompact{flex:1 1 220px}
.status{display:inline-flex;align-items:center;min-height:32px;padding:0 11px;border-radius:999px;background:rgba(255,255,255,.76);border:1px solid var(--line);color:var(--muted);font-size:.86rem;font-weight:600}
.status.ok{color:var(--ok);border-color:rgba(31,143,101,.22);background:rgba(31,143,101,.08)}
.status.warn{color:var(--warn);border-color:rgba(157,95,0,.22);background:rgba(157,95,0,.08)}
.status.bad{color:var(--bad);border-color:rgba(179,58,58,.22);background:rgba(179,58,58,.08)}
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:0}
.stat{border-radius:16px;padding:10px 12px}
.stat span{display:block;margin-bottom:4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.stat strong{display:block;font-size:clamp(1.6rem,3vw,2rem);line-height:1;letter-spacing:-.05em}
.stat strong.small{font-size:1.1rem;line-height:1.3;letter-spacing:-.02em}
.stat small{display:block;margin-top:4px;color:var(--muted);font-size:.82rem}
.banner,.empty{margin:0;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:rgba(255,255,255,.82)}
.banner{color:var(--bad);border-color:rgba(179,58,58,.24);background:rgba(179,58,58,.08)}
.list{display:grid;gap:12px;align-content:start}
.logList{display:grid;gap:10px;align-content:start;width:100%}
.card{overflow:hidden;border-radius:16px}
.card.new{border-color:rgba(31,143,101,.3);box-shadow:var(--card-shadow),0 0 0 1px rgba(31,143,101,.12)}
.summary{list-style:none;display:grid;gap:7px;padding:12px 14px;cursor:pointer;background:linear-gradient(180deg,rgba(255,250,244,.88),rgba(255,246,238,.96))}
.summary::-webkit-details-marker{display:none}
.top,.tags,.toolbar,.actions,.attachments{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.top{justify-content:space-between}
.head{min-width:0;flex:1 1 320px}
.head h2{margin:0;font-size:clamp(1rem,1.6vw,1.22rem);line-height:1.18;letter-spacing:-.03em;word-break:break-word}
.meta{margin:4px 0 0;color:var(--muted);font-size:.88rem;word-break:break-word}
.time,.tag{display:inline-flex;align-items:center;min-height:24px;padding:0 10px;border-radius:999px;font-size:.76rem;font-weight:700}
.time{background:rgba(31,41,51,.06)}
.tag{background:var(--accent-soft);color:#8d5632}
.tag.new{background:rgba(31,143,101,.1);color:var(--ok)}
.tag.bad{background:rgba(179,58,58,.1);color:var(--bad)}
.preview{margin:0;color:#324150;font-size:.9rem}
.body{display:grid;gap:10px;padding:10px 14px 14px;border-top:1px solid rgba(207,109,60,.14);background:var(--card-body)}
.toolbar{justify-content:space-between;align-items:center}
.tabs{display:inline-flex;gap:4px;padding:3px;border-radius:999px;background:rgba(207,109,60,.08)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
.metaCard{padding:9px 11px;border-radius:12px;background:rgba(255,255,255,.78);border:1px solid rgba(207,109,60,.12)}
.metaCard dt{margin:0 0 4px;color:var(--muted);font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.metaCard dd{margin:0;word-break:break-word}
.attachments{gap:6px}
.attachment{display:inline-flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;background:rgba(255,248,240,.96);border:1px solid rgba(207,109,60,.12);font-size:.84rem}
.attachmentLink{color:#8d5632;text-decoration:none;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
.attachmentLink:hover{transform:translateY(-1px);background:#fff;border-color:rgba(207,109,60,.28)}
.panel{overflow:hidden;border-radius:12px;border:1px solid rgba(207,109,60,.14);background:#fff}
.logEvent{width:100%;overflow:hidden;border-radius:16px;border:1px solid var(--log-line);background:var(--log-shell);box-shadow:var(--log-shadow)}
.secretCard{background:var(--secret-shell);border:1px solid var(--secret-line);box-shadow:var(--secret-shadow)}
.s3Card{background:var(--bucket-shell);border:1px solid var(--bucket-line);box-shadow:var(--bucket-shadow)}
.logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer}
.logSummary::-webkit-details-marker{display:none}
.secretSummary{background:linear-gradient(180deg,rgba(244,253,248,.9),rgba(236,249,242,.96))}
.s3Summary{background:linear-gradient(180deg,rgba(255,251,238,.92),rgba(255,246,223,.98))}
.logSummaryTop{display:flex;flex-wrap:wrap;gap:8px;justify-content:space-between;align-items:center}
.logMeta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.logSummaryActions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end}
.logTag{background:var(--info-soft);color:var(--info);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.secretTag{background:var(--secret-soft);color:var(--secret);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bucketTag{background:var(--bucket-soft);color:var(--bucket);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.logPreview{margin:0;color:#324150;font:600 .88rem/1.45 "Cascadia Code","Consolas",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.logBody{padding:8px 12px 12px;border-top:1px solid rgba(48,113,169,.14);background:var(--log-body)}
.secretBody{border-top-color:rgba(31,143,101,.14);background:var(--secret-body)}
.s3Body{border-top-color:rgba(181,137,37,.18);background:var(--bucket-body)}
.logCopyButton{box-shadow:none}
.logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff}
.secretValuePanel{display:grid;gap:10px}
.secretValuePanel pre{border-radius:12px;border:1px solid rgba(31,143,101,.14);padding:12px;background:linear-gradient(180deg,rgba(31,143,101,.04),transparent 140px),#fff}
.s3PreviewPanel{display:grid;gap:10px}
.s3PreviewImage{max-width:min(100%,640px);border-radius:12px;border:1px solid rgba(181,137,37,.16);background:#fff}
.logBody.wrapOff pre{white-space:pre;word-break:normal}
.tag.levelError{background:rgba(179,58,58,.12);color:var(--bad)}
.tag.levelWarn{background:rgba(157,95,0,.12);color:var(--warn)}
.tag.levelInfo{background:rgba(48,113,169,.12);color:var(--info)}
.tag.levelDebug{background:rgba(96,112,128,.12);color:var(--muted)}
.jsonSyntax .jsonKey{color:#b55f2d}
.jsonSyntax .jsonString{color:#1f8f65}
.jsonSyntax .jsonNumber{color:#2f6ea9}
.jsonSyntax .jsonBoolean{color:#9d5f00}
.jsonSyntax .jsonNull{color:#b33a3a}
iframe{width:100%;min-height:560px;border:none;background:#fff}
pre{margin:0;padding:12px;white-space:pre-wrap;word-break:break-word;overflow:auto;font:12.5px/1.45 "Cascadia Code","Consolas",monospace;color:#102030;background:linear-gradient(180deg,rgba(207,109,60,.04),transparent 140px),#fff}
.placeholder,.inlineError{padding:12px}
.inlineError{color:var(--bad)}
body[data-theme="dark"]{color-scheme:dark;background:radial-gradient(circle at top left,rgba(207,109,60,.12),transparent 28%),radial-gradient(circle at top right,rgba(48,113,169,.12),transparent 26%),linear-gradient(180deg,#10161d,#17202a)}
body[data-theme="dark"] .heroShell,
body[data-theme="dark"] .toolControls,
body[data-theme="dark"] .stat{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.16);box-shadow:0 14px 30px rgba(0,0,0,.32)}
body[data-theme="dark"] .card{background:linear-gradient(180deg,rgba(50,35,28,.96),rgba(31,25,22,.98));border-color:rgba(207,109,60,.24);box-shadow:0 18px 34px rgba(0,0,0,.34)}
body[data-theme="dark"] .logEvent{background:linear-gradient(180deg,rgba(18,31,45,.96),rgba(14,24,36,.98));border-color:rgba(73,144,204,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)}
body[data-theme="dark"] .secretCard{background:linear-gradient(180deg,rgba(19,39,31,.96),rgba(14,30,24,.98));border-color:rgba(64,170,126,.22);box-shadow:0 16px 32px rgba(0,0,0,.34)}
body[data-theme="dark"] .s3Card{background:linear-gradient(180deg,rgba(52,42,17,.96),rgba(37,30,13,.98));border-color:rgba(181,137,37,.24);box-shadow:0 16px 32px rgba(0,0,0,.34)}
body[data-theme="dark"] .healthBadge{background:rgba(15,21,30,.84);border-color:rgba(148,163,184,.18);box-shadow:0 10px 22px rgba(0,0,0,.28)}
body[data-theme="dark"] .healthBadge.active{border-color:rgba(207,109,60,.32);background:rgba(207,109,60,.18);box-shadow:0 12px 26px rgba(0,0,0,.3)}
body[data-theme="dark"] .healthBadge.active .healthBadgeName,
body[data-theme="dark"] .healthBadge.active .healthBadgeSummary{color:#f8ede6}
body[data-theme="dark"] .tab{color:#aab8c8}
body[data-theme="dark"] .tab.active,
body[data-theme="dark"] .ghost,
body[data-theme="dark"] .mini,
body[data-theme="dark"] .chip,
body[data-theme="dark"] .status,
body[data-theme="dark"] .search{background:rgba(18,25,35,.88);border-color:rgba(148,163,184,.18);color:#edf2f7}
body[data-theme="dark"] .chip select,
body[data-theme="dark"] .search::placeholder{color:#9fb0c2}
body[data-theme="dark"] .ghost,
body[data-theme="dark"] .mini,
body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)}
body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))}
body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)}
body[data-theme="dark"] .logSummary{background:linear-gradient(180deg,rgba(21,34,47,.94),rgba(16,27,39,.98))}
body[data-theme="dark"] .logBody{background:#13212d;border-top-color:rgba(73,144,204,.18)}
body[data-theme="dark"] .secretSummary{background:linear-gradient(180deg,rgba(21,43,34,.94),rgba(16,34,27,.98))}
body[data-theme="dark"] .secretBody{background:#12241c;border-top-color:rgba(64,170,126,.18)}
body[data-theme="dark"] .s3Summary{background:linear-gradient(180deg,rgba(53,41,19,.94),rgba(39,31,15,.98))}
body[data-theme="dark"] .s3Body{background:#241d10;border-top-color:rgba(181,137,37,.18)}
body[data-theme="dark"] .metaCard{background:rgba(17,25,35,.64);border-color:rgba(148,163,184,.14)}
body[data-theme="dark"] .attachment{background:rgba(50,35,28,.9);border-color:rgba(207,109,60,.18)}
body[data-theme="dark"] .attachmentLink{color:#f6c4a9}
body[data-theme="dark"] .attachmentLink:hover{background:rgba(75,52,39,.96);border-color:rgba(246,196,169,.26)}
body[data-theme="dark"] .panel,
body[data-theme="dark"] pre,
body[data-theme="dark"] .logBody pre{background:linear-gradient(180deg,rgba(73,144,204,.06),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .secretValuePanel pre{background:linear-gradient(180deg,rgba(64,170,126,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .s3PreviewPanel pre{background:linear-gradient(180deg,rgba(181,137,37,.08),transparent 140px),#0f1722;color:#e8edf3;border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .panel{border-color:rgba(148,163,184,.14)}
body[data-theme="dark"] .banner,
body[data-theme="dark"] .empty{background:rgba(15,21,30,.82);border-color:rgba(148,163,184,.16)}
body[data-theme="dark"] .time{background:rgba(148,163,184,.12);color:#e8edf3}
body[data-theme="dark"] .tag{background:rgba(207,109,60,.14);color:#f0c2aa}
body[data-theme="dark"] .logTag{background:rgba(73,144,204,.16);color:#93cfff}
body[data-theme="dark"] .secretTag{background:rgba(64,170,126,.16);color:#9fe0be}
body[data-theme="dark"] .bucketTag{background:rgba(181,137,37,.16);color:#f1d38c}
body[data-theme="dark"] .preview,
body[data-theme="dark"] .logPreview,
body[data-theme="dark"] .metaCard dd,
body[data-theme="dark"] .head h2,
body[data-theme="dark"] .stat strong,
body[data-theme="dark"] h1{color:#edf2f7}
body[data-theme="dark"] .jsonSyntax .jsonKey{color:#f0b08a}
body[data-theme="dark"] .jsonSyntax .jsonString{color:#80d5b0}
body[data-theme="dark"] .jsonSyntax .jsonNumber{color:#94c9ff}
body[data-theme="dark"] .jsonSyntax .jsonBoolean{color:#f0c274}
body[data-theme="dark"] .jsonSyntax .jsonNull{color:#ff9c9c}
body[data-theme="dark"] .meta,
body[data-theme="dark"] .helper,
body[data-theme="dark"] .lede,
body[data-theme="dark"] .stat small,
body[data-theme="dark"] .stat span,
body[data-theme="dark"] .chip,
body[data-theme="dark"] .tab{color:#aab8c8}
body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7}
body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)}
@media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row,.heroActions{align-items:stretch}.heroTopRow{justify-content:stretch;flex-basis:100%}.heroStatusRow{align-items:flex-start}.heroStatusLabel,.healthStrip{flex-basis:100%}.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.healthBadge{justify-content:flex-start}.logSummaryTop,.logSummaryActions{align-items:flex-start}.contentPane{min-height:300px}iframe{min-height:420px}}
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function clientApp(config) {
const THEME_STORAGE_KEY = "localstack-inspector-theme";
const PANEL_STORAGE_KEY = "localstack-inspector-panel";
const EMAIL_PREFERENCES_STORAGE_KEY = "localstack-inspector-email-preferences";
const LOG_PREFERENCES_STORAGE_KEY = "localstack-inspector-log-preferences";
const SECRET_PREFERENCES_STORAGE_KEY = "localstack-inspector-secret-preferences";
const S3_PREFERENCES_STORAGE_KEY = "localstack-inspector-s3-preferences";
const HEALTH_REFRESH_MS = 30000;
const REFRESH_INTERVALS = [5000, 10000, 15000, 30000, 60000];
const LOG_WINDOWS = [300000, 900000, 3600000, 21600000, 86400000];
const LOG_LIMITS = [100, 200, 300, 500];
const storedEmailPreferences = getStoredPreferences(EMAIL_PREFERENCES_STORAGE_KEY);
const storedLogPreferences = getStoredPreferences(LOG_PREFERENCES_STORAGE_KEY);
const storedSecretPreferences = getStoredPreferences(SECRET_PREFERENCES_STORAGE_KEY);
const storedS3Preferences = getStoredPreferences(S3_PREFERENCES_STORAGE_KEY);
const appState = {
panel: getInitialPanel(),
logsReady: false,
secretsReady: false,
s3Ready: false,
theme: getInitialTheme()
};
const state = {
messages: [],
filtered: [],
search: getStoredText(storedEmailPreferences?.search),
auto: getStoredBoolean(storedEmailPreferences?.auto, true),
interval: getStoredNumber(storedEmailPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs),
loading: false,
error: "",
updatedAt: 0,
source: "initial",
duration: 0,
parseErrors: 0,
newest: "",
newIds: new Set(),
knownIds: new Set(),
openIds: new Set(),
views: {},
raw: {},
listSignature: ""
};
const logsState = {
groups: [],
streams: [],
events: [],
filtered: [],
group: getStoredText(storedLogPreferences?.group, config.defaultLogGroup || ""),
stream: getStoredText(storedLogPreferences?.stream),
search: getStoredText(storedLogPreferences?.search),
auto: getStoredBoolean(storedLogPreferences?.auto, true),
interval: getStoredNumber(storedLogPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs),
windowMs: getStoredNumber(storedLogPreferences?.windowMs, LOG_WINDOWS, config.defaultLogWindowMs),
limit: getStoredNumber(storedLogPreferences?.limit, LOG_LIMITS, config.defaultLogLimit),
wrapLines: getStoredBoolean(storedLogPreferences?.wrapLines, true),
tailNewest: getStoredBoolean(storedLogPreferences?.tailNewest, false),
loading: false,
error: "",
updatedAt: 0,
source: "initial",
duration: 0,
newest: 0,
searchedLogStreams: 0,
openIds: new Set(),
listSignature: ""
};
const secretsState = {
items: [],
filtered: [],
search: getStoredText(storedSecretPreferences?.search),
auto: getStoredBoolean(storedSecretPreferences?.auto, true),
interval: getStoredNumber(storedSecretPreferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs),
loading: false,
error: "",
updatedAt: 0,
source: "initial",
duration: 0,
newest: "",
openIds: new Set(),
values: {},
revealedIds: new Set(),
listSignature: ""
};
const s3State = {
buckets: [],
objects: [],
filtered: [],
bucket: getStoredText(storedS3Preferences?.bucket, config.defaultS3Bucket || ""),
prefix: getStoredText(storedS3Preferences?.prefix),
search: getStoredText(storedS3Preferences?.search),
auto: getStoredBoolean(storedS3Preferences?.auto, true),
interval: getStoredNumber(storedS3Preferences?.interval, REFRESH_INTERVALS, config.defaultRefreshMs),
loading: false,
error: "",
updatedAt: 0,
source: "initial",
duration: 0,
newest: "",
openIds: new Set(),
previews: {},
listSignature: ""
};
const healthState = {
services: {},
loading: false,
error: "",
updatedAt: 0,
source: "initial"
};
const el = {
themeToggle: document.getElementById("themeToggle"),
resetStateButton: document.getElementById("resetStateButton"),
healthRefreshButton: document.getElementById("healthRefreshButton"),
healthStrip: document.getElementById("healthStrip"),
emailsPanel: document.getElementById("emailsPanel"),
logsPanel: document.getElementById("logsPanel"),
secretsPanel: document.getElementById("secretsPanel"),
s3Panel: document.getElementById("s3Panel"),
refreshButton: document.getElementById("refreshButton"),
autoToggle: document.getElementById("autoToggle"),
intervalSelect: document.getElementById("intervalSelect"),
searchInput: document.getElementById("searchInput"),
clearSearchButton: document.getElementById("clearSearchButton"),
expandAllButton: document.getElementById("expandAllButton"),
collapseAllButton: document.getElementById("collapseAllButton"),
scrollToTopButton: document.getElementById("scrollToTopButton"),
statusChip: document.getElementById("statusChip"),
totalStat: document.getElementById("totalStat"),
visibleStat: document.getElementById("visibleStat"),
newStat: document.getElementById("newStat"),
newestStat: document.getElementById("newestStat"),
updatedStat: document.getElementById("updatedStat"),
fetchStat: document.getElementById("fetchStat"),
fetchDetail: document.getElementById("fetchDetail"),
banner: document.getElementById("banner"),
empty: document.getElementById("empty"),
list: document.getElementById("list"),
emailsContentPane: document.getElementById("emailsContentPane"),
logsRefreshButton: document.getElementById("logsRefreshButton"),
logsAutoToggle: document.getElementById("logsAutoToggle"),
logsIntervalSelect: document.getElementById("logsIntervalSelect"),
logsGroupSelect: document.getElementById("logsGroupSelect"),
logsStreamSelect: document.getElementById("logsStreamSelect"),
logsWindowSelect: document.getElementById("logsWindowSelect"),
logsLimitSelect: document.getElementById("logsLimitSelect"),
logsSearchInput: document.getElementById("logsSearchInput"),
logsClearSearchButton: document.getElementById("logsClearSearchButton"),
logsWrapToggle: document.getElementById("logsWrapToggle"),
logsTailToggle: document.getElementById("logsTailToggle"),
logsExpandAllButton: document.getElementById("logsExpandAllButton"),
logsCollapseAllButton: document.getElementById("logsCollapseAllButton"),
logsScrollToTopButton: document.getElementById("logsScrollToTopButton"),
logsStatusChip: document.getElementById("logsStatusChip"),
logsTotalStat: document.getElementById("logsTotalStat"),
logsVisibleStat: document.getElementById("logsVisibleStat"),
logsStreamsStat: document.getElementById("logsStreamsStat"),
logsNewestStat: document.getElementById("logsNewestStat"),
logsUpdatedStat: document.getElementById("logsUpdatedStat"),
logsFetchStat: document.getElementById("logsFetchStat"),
logsFetchDetail: document.getElementById("logsFetchDetail"),
logsBanner: document.getElementById("logsBanner"),
logsEmpty: document.getElementById("logsEmpty"),
logsList: document.getElementById("logsList"),
logsContentPane: document.getElementById("logsContentPane"),
secretsRefreshButton: document.getElementById("secretsRefreshButton"),
secretsAutoToggle: document.getElementById("secretsAutoToggle"),
secretsIntervalSelect: document.getElementById("secretsIntervalSelect"),
secretsSearchInput: document.getElementById("secretsSearchInput"),
secretsClearSearchButton: document.getElementById("secretsClearSearchButton"),
secretsExpandAllButton: document.getElementById("secretsExpandAllButton"),
secretsCollapseAllButton: document.getElementById("secretsCollapseAllButton"),
secretsScrollToTopButton: document.getElementById("secretsScrollToTopButton"),
secretsStatusChip: document.getElementById("secretsStatusChip"),
secretsTotalStat: document.getElementById("secretsTotalStat"),
secretsVisibleStat: document.getElementById("secretsVisibleStat"),
secretsLoadedStat: document.getElementById("secretsLoadedStat"),
secretsNewestStat: document.getElementById("secretsNewestStat"),
secretsUpdatedStat: document.getElementById("secretsUpdatedStat"),
secretsFetchStat: document.getElementById("secretsFetchStat"),
secretsFetchDetail: document.getElementById("secretsFetchDetail"),
secretsBanner: document.getElementById("secretsBanner"),
secretsEmpty: document.getElementById("secretsEmpty"),
secretsList: document.getElementById("secretsList"),
secretsContentPane: document.getElementById("secretsContentPane"),
s3RefreshButton: document.getElementById("s3RefreshButton"),
s3AutoToggle: document.getElementById("s3AutoToggle"),
s3IntervalSelect: document.getElementById("s3IntervalSelect"),
s3BucketSelect: document.getElementById("s3BucketSelect"),
s3PrefixInput: document.getElementById("s3PrefixInput"),
s3ApplyPrefixButton: document.getElementById("s3ApplyPrefixButton"),
s3SearchInput: document.getElementById("s3SearchInput"),
s3ClearSearchButton: document.getElementById("s3ClearSearchButton"),
s3ExpandAllButton: document.getElementById("s3ExpandAllButton"),
s3CollapseAllButton: document.getElementById("s3CollapseAllButton"),
s3ScrollToTopButton: document.getElementById("s3ScrollToTopButton"),
s3StatusChip: document.getElementById("s3StatusChip"),
s3TotalStat: document.getElementById("s3TotalStat"),
s3VisibleStat: document.getElementById("s3VisibleStat"),
s3BucketsStat: document.getElementById("s3BucketsStat"),
s3NewestStat: document.getElementById("s3NewestStat"),
s3UpdatedStat: document.getElementById("s3UpdatedStat"),
s3FetchStat: document.getElementById("s3FetchStat"),
s3FetchDetail: document.getElementById("s3FetchDetail"),
s3Banner: document.getElementById("s3Banner"),
s3Empty: document.getElementById("s3Empty"),
s3List: document.getElementById("s3List"),
s3ContentPane: document.getElementById("s3ContentPane")
};
el.autoToggle.checked = state.auto;
el.intervalSelect.value = String(state.interval);
el.searchInput.value = state.search;
el.logsAutoToggle.checked = logsState.auto;
el.logsIntervalSelect.value = String(logsState.interval);
el.logsWindowSelect.value = String(logsState.windowMs);
el.logsLimitSelect.value = String(logsState.limit);
el.logsSearchInput.value = logsState.search;
el.logsWrapToggle.checked = logsState.wrapLines;
el.logsTailToggle.checked = logsState.tailNewest;
el.secretsAutoToggle.checked = secretsState.auto;
el.secretsIntervalSelect.value = String(secretsState.interval);
el.secretsSearchInput.value = secretsState.search;
el.s3AutoToggle.checked = s3State.auto;
el.s3IntervalSelect.value = String(s3State.interval);
el.s3PrefixInput.value = s3State.prefix;
el.s3SearchInput.value = s3State.search;
applyTheme(appState.theme);
persistPanel();
persistEmailPreferences();
persistLogPreferences();
persistSecretPreferences();
persistS3Preferences();
wire();
renderWorkspace();
renderAll();
renderLogsAll();
renderSecretsAll();
renderS3All();
renderHealthStrip();
if (appState.panel === "logs") {
ensureLogsReady();
} else if (appState.panel === "secrets") {
ensureSecretsReady();
} else if (appState.panel === "s3") {
ensureS3Ready();
} else {
refreshMessages("initial");
}
refreshHealthSummary("initial");
window.setInterval(() => {
if (!document.hidden) {
refreshHealthSummary("auto");
}
}, HEALTH_REFRESH_MS);
window.setInterval(() => {
renderLiveClock();
renderLogsLiveClock();
renderSecretsLiveClock();
renderS3LiveClock();
renderHealthStrip();
}, 1000);
function wire() {
el.themeToggle.addEventListener("click", () => {
applyTheme(appState.theme === "dark" ? "light" : "dark");
});
el.resetStateButton.addEventListener("click", () => {
resetSavedState();
});
el.healthRefreshButton.addEventListener("click", () => {
refreshHealthSummary("manual");
});
el.healthStrip.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-health-panel]");
if (!button) {
return;
}
await setPanel(button.dataset.healthPanel);
});
el.refreshButton.addEventListener("click", () => refreshMessages("manual"));
el.autoToggle.addEventListener("change", () => {
state.auto = el.autoToggle.checked;
persistEmailPreferences();
scheduleRefresh();
renderStatus();
});
el.intervalSelect.addEventListener("change", () => {
state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs;
persistEmailPreferences();
scheduleRefresh();
renderStatus();
});
el.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
applyFilter();
});
el.clearSearchButton.addEventListener("click", () => {
state.search = "";
el.searchInput.value = "";
applyFilter();
});
el.expandAllButton.addEventListener("click", () => {
state.filtered.forEach((message) => state.openIds.add(message.id));
syncCardExpansion();
});
el.collapseAllButton.addEventListener("click", () => {
state.openIds.clear();
syncCardExpansion();
});
el.scrollToTopButton.addEventListener("click", () => {
scrollPaneToTop(el.emailsContentPane);
});
el.emailsContentPane.addEventListener("scroll", () => {
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
});
el.list.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
if (!button) {
return;
}
const id = button.dataset.id;
const message = getMessage(id);
if (!message) {
return;
}
if (button.dataset.action === "view") {
state.views[id] = button.dataset.view;
renderList();
return;
}
if (button.dataset.action === "load-raw") {
await loadRaw(id);
renderList();
return;
}
if (button.dataset.action === "copy-raw") {
const raw = await loadRaw(id);
if (raw) {
await copyText(raw);
setStatus("Raw message copied to the clipboard.", "ok");
}
}
});
el.logsRefreshButton.addEventListener("click", () => refreshLogs("manual"));
el.logsAutoToggle.addEventListener("change", () => {
logsState.auto = el.logsAutoToggle.checked;
persistLogPreferences();
scheduleLogsRefresh();
renderLogsStatus();
});
el.logsIntervalSelect.addEventListener("change", () => {
logsState.interval = Number(el.logsIntervalSelect.value) || config.defaultRefreshMs;
persistLogPreferences();
scheduleLogsRefresh();
renderLogsStatus();
});
el.logsWindowSelect.addEventListener("change", () => {
logsState.windowMs = Number(el.logsWindowSelect.value) || config.defaultLogWindowMs;
persistLogPreferences();
refreshLogs("window");
});
el.logsLimitSelect.addEventListener("change", () => {
logsState.limit = Number(el.logsLimitSelect.value) || config.defaultLogLimit;
persistLogPreferences();
refreshLogs("limit");
});
el.logsSearchInput.addEventListener("input", (event) => {
logsState.search = event.target.value;
applyLogsFilter();
});
el.logsClearSearchButton.addEventListener("click", () => {
logsState.search = "";
el.logsSearchInput.value = "";
applyLogsFilter();
});
el.logsWrapToggle.addEventListener("change", () => {
logsState.wrapLines = el.logsWrapToggle.checked;
persistLogPreferences();
renderLogsList();
});
el.logsTailToggle.addEventListener("change", () => {
logsState.tailNewest = el.logsTailToggle.checked;
persistLogPreferences();
});
el.logsExpandAllButton.addEventListener("click", () => {
logsState.filtered.forEach((event) => logsState.openIds.add(event.id));
renderLogsList();
});
el.logsCollapseAllButton.addEventListener("click", () => {
logsState.openIds.clear();
renderLogsList();
});
el.logsScrollToTopButton.addEventListener("click", () => {
scrollPaneToTop(el.logsContentPane);
});
el.logsContentPane.addEventListener("scroll", () => {
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
});
el.logsGroupSelect.addEventListener("change", async () => {
logsState.group = el.logsGroupSelect.value;
logsState.stream = "";
persistLogPreferences();
await refreshLogStreams();
await refreshLogs("group");
});
el.logsStreamSelect.addEventListener("change", () => {
logsState.stream = el.logsStreamSelect.value;
persistLogPreferences();
refreshLogs("stream");
});
el.logsList.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-log-action]");
if (!button) {
return;
}
event.preventDefault();
event.stopPropagation();
const logEvent = getLogEvent(button.dataset.id);
if (!logEvent) {
return;
}
if (button.dataset.logAction === "copy") {
await copyText(formatLogMessage(logEvent.message));
setLogsStatus("Log payload copied to the clipboard.", "ok");
}
});
el.secretsRefreshButton.addEventListener("click", () => refreshSecrets("manual"));
el.secretsAutoToggle.addEventListener("change", () => {
secretsState.auto = el.secretsAutoToggle.checked;
persistSecretPreferences();
scheduleSecretsRefresh();
renderSecretsStatus();
});
el.secretsIntervalSelect.addEventListener("change", () => {
secretsState.interval = Number(el.secretsIntervalSelect.value) || config.defaultRefreshMs;
persistSecretPreferences();
scheduleSecretsRefresh();
renderSecretsStatus();
});
el.secretsSearchInput.addEventListener("input", (event) => {
secretsState.search = event.target.value;
applySecretsFilter();
});
el.secretsClearSearchButton.addEventListener("click", () => {
secretsState.search = "";
el.secretsSearchInput.value = "";
applySecretsFilter();
});
el.secretsExpandAllButton.addEventListener("click", () => {
secretsState.filtered.forEach((secret) => secretsState.openIds.add(secret.id));
syncSecretExpansion();
});
el.secretsCollapseAllButton.addEventListener("click", () => {
secretsState.openIds.clear();
syncSecretExpansion();
});
el.secretsScrollToTopButton.addEventListener("click", () => {
scrollPaneToTop(el.secretsContentPane);
});
el.secretsContentPane.addEventListener("scroll", () => {
updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton);
});
el.secretsList.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-secret-action]");
if (!button) {
return;
}
const id = button.dataset.id;
const secret = getSecret(id);
if (!secret) {
return;
}
if (button.dataset.secretAction === "load-value") {
await ensureSecretValue(id, { force: false });
return;
}
if (button.dataset.secretAction === "reload-value") {
await ensureSecretValue(id, { force: true });
return;
}
if (button.dataset.secretAction === "copy-value") {
const entry = await ensureSecretValue(id, { force: false });
if (entry?.status === "loaded") {
await copyText(entry.copyValue);
setSecretsStatus("Secret value copied to the clipboard.", "ok");
}
return;
}
if (button.dataset.secretAction === "toggle-reveal") {
toggleSecretReveal(id);
return;
}
if (button.dataset.secretAction === "copy-name") {
await copyText(secret.name || "");
setSecretsStatus("Secret name copied to the clipboard.", "ok");
return;
}
if (button.dataset.secretAction === "copy-arn") {
await copyText(secret.arn || "");
setSecretsStatus("Secret ARN copied to the clipboard.", "ok");
return;
}
if (button.dataset.secretAction === "copy-env") {
const entry = await ensureSecretValue(id, { force: false });
if (entry?.status === "loaded") {
await copyText(`${secret.name}=${entry.copyValue}`);
setSecretsStatus("Secret env line copied to the clipboard.", "ok");
}
}
});
el.s3RefreshButton.addEventListener("click", async () => {
await refreshS3Buckets();
await refreshS3("manual");
});
el.s3AutoToggle.addEventListener("change", () => {
s3State.auto = el.s3AutoToggle.checked;
persistS3Preferences();
scheduleS3Refresh();
renderS3Status();
});
el.s3IntervalSelect.addEventListener("change", () => {
s3State.interval = Number(el.s3IntervalSelect.value) || config.defaultRefreshMs;
persistS3Preferences();
scheduleS3Refresh();
renderS3Status();
});
el.s3BucketSelect.addEventListener("change", () => {
s3State.bucket = el.s3BucketSelect.value;
persistS3Preferences();
refreshS3("bucket");
});
el.s3PrefixInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
s3State.prefix = el.s3PrefixInput.value.trim();
persistS3Preferences();
refreshS3("prefix");
}
});
el.s3ApplyPrefixButton.addEventListener("click", () => {
s3State.prefix = el.s3PrefixInput.value.trim();
persistS3Preferences();
refreshS3("prefix");
});
el.s3SearchInput.addEventListener("input", (event) => {
s3State.search = event.target.value;
applyS3Filter();
});
el.s3ClearSearchButton.addEventListener("click", () => {
s3State.search = "";
el.s3SearchInput.value = "";
applyS3Filter();
});
el.s3ExpandAllButton.addEventListener("click", () => {
s3State.filtered.forEach((object) => s3State.openIds.add(object.id));
syncS3Expansion();
});
el.s3CollapseAllButton.addEventListener("click", () => {
s3State.openIds.clear();
syncS3Expansion();
});
el.s3ScrollToTopButton.addEventListener("click", () => {
scrollPaneToTop(el.s3ContentPane);
});
el.s3ContentPane.addEventListener("scroll", () => {
updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton);
});
el.s3List.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-s3-action]");
if (!button) {
return;
}
const id = button.dataset.id;
const object = getS3Object(id);
if (!object) {
return;
}
if (button.dataset.s3Action === "load-preview") {
await ensureS3Preview(id, { force: false });
return;
}
if (button.dataset.s3Action === "reload-preview") {
await ensureS3Preview(id, { force: true });
return;
}
if (button.dataset.s3Action === "copy-key") {
await copyText(object.key);
setS3Status("Object key copied to the clipboard.", "ok");
return;
}
if (button.dataset.s3Action === "copy-uri") {
await copyText(`s3://${object.bucket}/${object.key}`);
setS3Status("S3 URI copied to the clipboard.", "ok");
}
});
document.addEventListener("visibilitychange", () => {
window.clearTimeout(state.timer);
window.clearTimeout(logsState.timer);
window.clearTimeout(secretsState.timer);
window.clearTimeout(s3State.timer);
if (document.hidden) {
renderStatus();
renderLogsStatus();
renderSecretsStatus();
renderS3Status();
return;
}
refreshHealthSummary("visibility");
if (appState.panel === "s3") {
if (s3State.auto) {
refreshS3("visibility");
} else {
renderS3Status();
}
return;
}
if (appState.panel === "secrets") {
if (secretsState.auto) {
refreshSecrets("visibility");
} else {
renderSecretsStatus();
}
return;
}
if (appState.panel === "logs") {
if (logsState.auto) {
refreshLogs("visibility");
} else {
renderLogsStatus();
}
return;
}
if (state.auto) {
refreshMessages("visibility");
} else {
renderStatus();
}
});
window.addEventListener("keydown", (event) => {
const isField =
event.target instanceof HTMLElement &&
(event.target.matches("input,textarea,select") || event.target.isContentEditable);
if (!isField && event.key.toLowerCase() === "r") {
event.preventDefault();
if (appState.panel === "logs") {
refreshLogs("keyboard");
return;
}
if (appState.panel === "secrets") {
refreshSecrets("keyboard");
return;
}
if (appState.panel === "s3") {
refreshS3("keyboard");
return;
}
refreshMessages("keyboard");
}
});
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton);
updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton);
}
async function setPanel(panel) {
if (!panel || panel === appState.panel) {
if (panel === "logs") {
await ensureLogsReady();
} else if (panel === "secrets") {
await ensureSecretsReady();
} else if (panel === "s3") {
await ensureS3Ready();
}
renderWorkspace();
return;
}
appState.panel = panel;
persistPanel();
renderWorkspace();
if (panel === "logs") {
await ensureLogsReady();
} else if (panel === "secrets") {
await ensureSecretsReady();
} else if (panel === "s3") {
await ensureS3Ready();
} else if (!state.updatedAt && !state.loading) {
await refreshMessages("panel");
}
scheduleRefresh();
scheduleLogsRefresh();
scheduleSecretsRefresh();
scheduleS3Refresh();
renderStatus();
renderLogsStatus();
renderSecretsStatus();
renderS3Status();
}
function renderWorkspace() {
el.emailsPanel.hidden = appState.panel !== "emails";
el.logsPanel.hidden = appState.panel !== "logs";
el.secretsPanel.hidden = appState.panel !== "secrets";
el.s3Panel.hidden = appState.panel !== "s3";
renderHealthStrip();
}
async function ensureLogsReady() {
if (appState.logsReady) {
return;
}
await refreshLogGroups();
appState.logsReady = !logsState.error;
if (logsState.group) {
await refreshLogs("initial");
}
}
async function ensureSecretsReady() {
if (appState.secretsReady) {
return;
}
await refreshSecrets("initial");
appState.secretsReady = !secretsState.error;
}
async function ensureS3Ready() {
if (appState.s3Ready) {
return;
}
await refreshS3Buckets();
appState.s3Ready = !s3State.error;
if (s3State.bucket) {
await refreshS3("initial");
}
}
async function refreshMessages(source) {
if (state.loading) {
return;
}
let shouldRenderList = false;
state.loading = true;
state.source = source;
state.error = "";
renderStatus();
renderFetch();
try {
const response = await fetch("/api/messages", { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const nextIds = new Set(messages.map((message) => message.id));
const nextSignature = computeListSignature(messages);
shouldRenderList = nextSignature !== state.listSignature;
state.newIds =
state.updatedAt && shouldRenderList
? new Set(messages.filter((message) => !state.knownIds.has(message.id)).map((message) => message.id))
: state.newIds;
state.knownIds = nextIds;
state.messages = messages;
state.duration = payload.fetchDurationMs || 0;
state.parseErrors = payload.parseErrors || 0;
state.newest = payload.latestMessageTimestamp || "";
state.updatedAt = Date.now();
state.listSignature = nextSignature;
pruneState();
applyFilter(shouldRenderList);
setStatus(`Updated ${messages.length} message${messages.length === 1 ? "" : "s"}.`, "ok");
} catch (error) {
state.error = error.message || "Unknown refresh error";
setStatus(`Refresh failed: ${state.error}`, "bad");
} finally {
state.loading = false;
scheduleRefresh();
renderAll({ renderList: shouldRenderList });
}
}
async function refreshLogGroups() {
try {
const response = await fetch("/api/logs/groups", { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
logsState.groups = Array.isArray(payload.groups) ? payload.groups : [];
const availableGroups = logsState.groups.map((group) => group.name).filter(Boolean);
if (!availableGroups.includes(logsState.group)) {
logsState.group = availableGroups.includes(config.defaultLogGroup)
? config.defaultLogGroup
: availableGroups[0] || "";
}
logsState.error = "";
await refreshLogStreams();
} catch (error) {
logsState.error = error.message || "Unknown log group refresh error";
} finally {
persistLogPreferences();
renderLogsAll();
}
}
async function refreshLogStreams() {
if (!logsState.group) {
logsState.streams = [];
logsState.stream = "";
renderLogsAll();
return;
}
try {
const response = await fetch(`/api/logs/streams?group=${encodeURIComponent(logsState.group)}`, {
cache: "no-store"
});
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
logsState.streams = Array.isArray(payload.streams) ? payload.streams : [];
if (!logsState.streams.some((stream) => stream.name === logsState.stream)) {
logsState.stream = "";
}
logsState.error = "";
} catch (error) {
logsState.streams = [];
logsState.stream = "";
logsState.error = error.message || "Unknown log stream refresh error";
} finally {
persistLogPreferences();
renderLogsAll();
}
}
async function refreshLogs(source) {
if (logsState.loading) {
return;
}
if (!appState.logsReady) {
await ensureLogsReady();
return;
}
if (!logsState.group) {
logsState.error = logsState.groups.length ? "Choose a log group to load events." : "No log groups found.";
renderLogsAll();
return;
}
let shouldRenderList = false;
logsState.loading = true;
logsState.source = source;
logsState.error = "";
renderLogsStatus();
renderLogsFetch();
try {
const params = new URLSearchParams({
group: logsState.group,
windowMs: String(logsState.windowMs),
limit: String(logsState.limit)
});
if (logsState.stream) {
params.set("stream", logsState.stream);
}
const response = await fetch(`/api/logs/events?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
const events = Array.isArray(payload.events) ? payload.events : [];
const nextSignature = computeLogListSignature(events);
shouldRenderList = nextSignature !== logsState.listSignature;
logsState.events = events;
logsState.duration = payload.fetchDurationMs || 0;
logsState.newest = payload.latestTimestamp || 0;
logsState.updatedAt = Date.now();
logsState.searchedLogStreams = payload.searchedLogStreams || (logsState.stream ? 1 : logsState.streams.length);
logsState.listSignature = nextSignature;
pruneLogsState();
applyLogsFilter(shouldRenderList);
setLogsStatus(`Updated ${logsState.events.length} log event${logsState.events.length === 1 ? "" : "s"}.`, "ok");
} catch (error) {
logsState.error = error.message || "Unknown log refresh error";
setLogsStatus(`Refresh failed: ${logsState.error}`, "bad");
} finally {
logsState.loading = false;
scheduleLogsRefresh();
renderLogsAll({ renderList: shouldRenderList });
if (shouldRenderList && logsState.tailNewest) {
scrollPaneToTop(el.logsContentPane);
}
}
}
async function refreshSecrets(source) {
if (secretsState.loading) {
return;
}
let shouldRenderList = false;
secretsState.loading = true;
secretsState.source = source;
secretsState.error = "";
renderSecretsStatus();
renderSecretsFetch();
try {
const response = await fetch("/api/secrets", { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
const items = Array.isArray(payload.secrets) ? payload.secrets : [];
const nextSignature = computeSecretsListSignature(items);
shouldRenderList = nextSignature !== secretsState.listSignature;
secretsState.items = items;
secretsState.duration = payload.fetchDurationMs || 0;
secretsState.newest = payload.latestTimestamp || "";
secretsState.updatedAt = Date.now();
secretsState.listSignature = nextSignature;
pruneSecretsState();
applySecretsFilter(shouldRenderList);
appState.secretsReady = true;
setSecretsStatus(`Updated ${items.length} secret${items.length === 1 ? "" : "s"}.`, "ok");
} catch (error) {
secretsState.error = error.message || "Unknown secrets refresh error";
appState.secretsReady = false;
setSecretsStatus(`Refresh failed: ${secretsState.error}`, "bad");
} finally {
secretsState.loading = false;
scheduleSecretsRefresh();
renderSecretsAll({ renderList: shouldRenderList });
}
}
async function refreshHealthSummary(source) {
if (healthState.loading && source === "auto") {
return;
}
healthState.loading = true;
healthState.source = source;
renderHealthStrip();
try {
const response = await fetch("/api/service-health", { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
healthState.services = payload.services || {};
healthState.updatedAt = Date.now();
healthState.error = "";
} catch (error) {
healthState.error = error.message || "Unknown health refresh error";
} finally {
healthState.loading = false;
renderHealthStrip();
}
}
async function refreshS3Buckets() {
try {
const response = await fetch("/api/s3/buckets", { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
s3State.buckets = Array.isArray(payload.buckets) ? payload.buckets : [];
const availableBuckets = s3State.buckets.map((bucket) => bucket.name).filter(Boolean);
if (!availableBuckets.includes(s3State.bucket)) {
s3State.bucket = availableBuckets.includes(config.defaultS3Bucket)
? config.defaultS3Bucket
: availableBuckets[0] || "";
}
s3State.error = "";
} catch (error) {
s3State.buckets = [];
s3State.bucket = "";
s3State.error = error.message || "Unknown S3 bucket refresh error";
} finally {
appState.s3Ready = !s3State.error;
persistS3Preferences();
renderS3All();
}
}
async function refreshS3(source) {
if (s3State.loading) {
return;
}
if (!appState.s3Ready) {
await ensureS3Ready();
return;
}
if (!s3State.bucket) {
s3State.error = s3State.buckets.length ? "Choose a bucket to load objects." : "No S3 buckets found.";
renderS3All();
return;
}
let shouldRenderList = false;
s3State.loading = true;
s3State.source = source;
s3State.error = "";
renderS3Status();
renderS3Fetch();
try {
const params = new URLSearchParams({
bucket: s3State.bucket
});
if (s3State.prefix) {
params.set("prefix", s3State.prefix);
}
const response = await fetch(`/api/s3/objects?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
const objects = Array.isArray(payload.objects) ? payload.objects : [];
const nextSignature = computeS3ListSignature(objects);
shouldRenderList = nextSignature !== s3State.listSignature;
s3State.objects = objects;
s3State.duration = payload.fetchDurationMs || 0;
s3State.newest = payload.latestTimestamp || "";
s3State.updatedAt = Date.now();
s3State.listSignature = nextSignature;
pruneS3State();
applyS3Filter(shouldRenderList);
appState.s3Ready = true;
setS3Status(`Updated ${objects.length} object${objects.length === 1 ? "" : "s"}.`, "ok");
} catch (error) {
s3State.error = error.message || "Unknown S3 refresh error";
appState.s3Ready = false;
setS3Status(`Refresh failed: ${s3State.error}`, "bad");
} finally {
s3State.loading = false;
scheduleS3Refresh();
renderS3All({ renderList: shouldRenderList });
}
}
function applyLogsFilter(shouldRenderList = true) {
const search = logsState.search.trim().toLowerCase();
logsState.filtered = !search
? [...logsState.events]
: logsState.events.filter((event) => logHaystack(event).includes(search));
persistLogPreferences();
renderLogsAll({ renderList: shouldRenderList });
}
function applySecretsFilter(shouldRenderList = true) {
const search = secretsState.search.trim().toLowerCase();
secretsState.filtered = !search
? [...secretsState.items]
: secretsState.items.filter((secret) => secretHaystack(secret).includes(search));
persistSecretPreferences();
renderSecretsAll({ renderList: shouldRenderList });
}
function applyS3Filter(shouldRenderList = true) {
const search = s3State.search.trim().toLowerCase();
s3State.filtered = !search
? [...s3State.objects]
: s3State.objects.filter((object) => s3Haystack(object).includes(search));
persistS3Preferences();
renderS3All({ renderList: shouldRenderList });
}
function pruneState() {
const ids = new Set(state.messages.map((message) => message.id));
state.openIds = new Set([...state.openIds].filter((id) => ids.has(id)));
state.newIds = new Set([...state.newIds].filter((id) => ids.has(id)));
Object.keys(state.views).forEach((id) => {
if (!ids.has(id)) {
delete state.views[id];
}
});
Object.keys(state.raw).forEach((id) => {
if (!ids.has(id)) {
delete state.raw[id];
}
});
}
function pruneLogsState() {
const ids = new Set(logsState.events.map((event) => event.id));
logsState.openIds = new Set([...logsState.openIds].filter((id) => ids.has(id)));
}
function pruneSecretsState() {
const ids = new Set(secretsState.items.map((secret) => secret.id));
secretsState.openIds = new Set([...secretsState.openIds].filter((id) => ids.has(id)));
Object.keys(secretsState.values).forEach((id) => {
if (!ids.has(id)) {
delete secretsState.values[id];
}
});
secretsState.revealedIds = new Set([...secretsState.revealedIds].filter((id) => ids.has(id)));
}
function pruneS3State() {
const ids = new Set(s3State.objects.map((object) => object.id));
s3State.openIds = new Set([...s3State.openIds].filter((id) => ids.has(id)));
Object.keys(s3State.previews).forEach((id) => {
if (!ids.has(id)) {
delete s3State.previews[id];
}
});
}
function applyFilter(shouldRenderList = true) {
const search = state.search.trim().toLowerCase();
state.filtered = !search
? [...state.messages]
: state.messages.filter((message) => haystack(message).includes(search));
persistEmailPreferences();
renderAll({ renderList: shouldRenderList });
}
function computeListSignature(messages) {
return messages
.map((message) =>
[
message.id,
message.timestampMs || 0,
message.rawSizeBytes || 0,
message.attachmentCount || 0,
message.hasHtml ? 1 : 0,
message.preview || "",
message.parseError || ""
].join("::")
)
.join("|");
}
function computeLogListSignature(events) {
return events
.map((event) =>
[event.id, event.timestamp || 0, event.ingestionTime || 0, event.logStreamName || "", event.message || ""].join(
"::"
)
)
.join("|");
}
function computeSecretsListSignature(items) {
return items
.map((secret) =>
[
secret.id,
secret.name || "",
secret.arn || "",
secret.description || "",
secret.lastChangedDate || "",
secret.createdDate || "",
secret.rotationEnabled ? 1 : 0,
secret.owningService || "",
secret.primaryRegion || "",
secret.versionCount || 0,
secret.tagCount || 0
].join("::")
)
.join("|");
}
function computeS3ListSignature(objects) {
return objects
.map((object) =>
[object.id, object.key || "", object.size || 0, object.lastModified || "", object.etag || ""].join("::")
)
.join("|");
}
function haystack(message) {
return [
message.subject,
message.from,
message.to,
message.replyTo,
message.preview,
message.textContent,
message.region,
...(message.attachments || []).flatMap((attachment) => [attachment.filename, attachment.contentType])
]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function logHaystack(event) {
return [event.logStreamName, event.message, event.preview].filter(Boolean).join(" ").toLowerCase();
}
function secretHaystack(secret) {
return [
secret.name,
secret.arn,
secret.description,
secret.primaryRegion,
secret.owningService,
...(secret.tags || []).flatMap((tag) => [tag.key, tag.value])
]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function s3Haystack(object) {
return [object.bucket, object.key, object.storageClass, object.etag].filter(Boolean).join(" ").toLowerCase();
}
function scheduleRefresh() {
window.clearTimeout(state.timer);
if (appState.panel !== "emails" || !state.auto || document.hidden || state.loading) {
return;
}
state.timer = window.setTimeout(() => refreshMessages("auto"), state.interval);
}
function scheduleLogsRefresh() {
window.clearTimeout(logsState.timer);
if (appState.panel !== "logs" || !logsState.auto || document.hidden || logsState.loading) {
return;
}
logsState.timer = window.setTimeout(() => refreshLogs("auto"), logsState.interval);
}
function scheduleSecretsRefresh() {
window.clearTimeout(secretsState.timer);
if (appState.panel !== "secrets" || !secretsState.auto || document.hidden || secretsState.loading) {
return;
}
secretsState.timer = window.setTimeout(() => refreshSecrets("auto"), secretsState.interval);
}
function scheduleS3Refresh() {
window.clearTimeout(s3State.timer);
if (appState.panel !== "s3" || !s3State.auto || document.hidden || s3State.loading) {
return;
}
s3State.timer = window.setTimeout(() => refreshS3("auto"), s3State.interval);
}
function renderAll(options = {}) {
const { renderList: shouldRenderList = true } = options;
renderStats();
renderFetch();
renderStatus();
if (shouldRenderList) {
renderList();
}
renderLiveClock();
}
function renderLogsAll(options = {}) {
const { renderList: shouldRenderList = true } = options;
renderLogsFilters();
renderLogsStats();
renderLogsFetch();
renderLogsStatus();
if (shouldRenderList) {
renderLogsList();
}
renderLogsLiveClock();
}
function renderSecretsAll(options = {}) {
const { renderList: shouldRenderList = true } = options;
renderSecretsStats();
renderSecretsFetch();
renderSecretsStatus();
if (shouldRenderList) {
renderSecretsList();
}
renderSecretsLiveClock();
}
function renderS3All(options = {}) {
const { renderList: shouldRenderList = true } = options;
renderS3Filters();
renderS3Stats();
renderS3Fetch();
renderS3Status();
if (shouldRenderList) {
renderS3List();
}
renderS3LiveClock();
}
function renderHealthStrip() {
const serviceOrder = [
{ key: "emails", panel: "emails", icon: "✉️", label: "SES Emails", shortLabel: "SES" },
{ key: "logs", panel: "logs", icon: "📜", label: "CloudWatch Logs", shortLabel: "Logs" },
{ key: "secrets", panel: "secrets", icon: "🔐", label: "Secrets Manager", shortLabel: "Secrets" },
{ key: "s3", panel: "s3", icon: "🪣", label: "S3 Explorer", shortLabel: "S3" }
];
el.healthStrip.innerHTML = serviceOrder
.map((service) => {
const entry = healthState.services?.[service.key];
const toneClass = healthState.loading && !entry ? "warn" : entry?.ok ? "ok" : entry ? "bad" : "";
const activeClass = service.panel === appState.panel ? "active" : "";
const className = ["healthBadge", toneClass, activeClass].filter(Boolean).join(" ");
const summary = entry?.summary || (healthState.loading ? "Checking..." : "Waiting");
const detail = entry?.detail || (healthState.error ? healthState.error : "Not checked yet");
const updatedMeta = healthState.updatedAt
? `Updated ${formatRelative(healthState.updatedAt)} via ${healthState.source}`
: "";
const titleParts = [`${service.label}: ${detail}`];
if (updatedMeta) {
titleParts.push(updatedMeta);
}
return `<button class="${className}" type="button" data-health-panel="${service.panel}" aria-pressed="${service.panel === appState.panel ? "true" : "false"}" title="${escapeHtml(
titleParts.join(" • ")
)}"><span class="healthBadgeName">${service.icon} ${escapeHtml(service.shortLabel)}</span><span class="healthBadgeSummary">${escapeHtml(summary)}</span></button>`;
})
.join("");
}
function renderStats() {
el.totalStat.textContent = String(state.messages.length);
el.visibleStat.textContent = `${state.filtered.length} visible${state.search ? " after search" : ""}`;
el.newStat.textContent = String(state.newIds.size);
el.newestStat.textContent = state.newest ? formatDateTime(state.newest) : "No messages";
}
function renderLogsFilters() {
const groups = logsState.groups.length
? logsState.groups.map((group) => `<option value="${escapeHtml(group.name)}">${escapeHtml(group.name)}</option>`)
: ['<option value="">No log groups</option>'];
const streams = [
'<option value="">All streams</option>',
...logsState.streams.map(
(stream) => `<option value="${escapeHtml(stream.name)}">${escapeHtml(stream.name)}</option>`
)
];
el.logsGroupSelect.innerHTML = groups.join("");
el.logsStreamSelect.innerHTML = streams.join("");
if (logsState.group) {
el.logsGroupSelect.value = logsState.group;
}
el.logsStreamSelect.value = logsState.stream;
el.logsWrapToggle.checked = logsState.wrapLines;
el.logsTailToggle.checked = logsState.tailNewest;
}
function renderS3Filters() {
const bucketOptions = s3State.buckets.length
? s3State.buckets.map(
(bucket) => `<option value="${escapeHtml(bucket.name)}">${escapeHtml(bucket.name)}</option>`
)
: ['<option value="">No buckets</option>'];
el.s3BucketSelect.innerHTML = bucketOptions.join("");
if (s3State.bucket) {
el.s3BucketSelect.value = s3State.bucket;
}
el.s3PrefixInput.value = s3State.prefix;
el.s3SearchInput.value = s3State.search;
}
function renderLogsStats() {
el.logsTotalStat.textContent = String(logsState.events.length);
el.logsVisibleStat.textContent = `${logsState.filtered.length} visible${logsState.search ? " after search" : ""}`;
el.logsStreamsStat.textContent = String(logsState.streams.length || logsState.searchedLogStreams || 0);
el.logsNewestStat.textContent = logsState.newest ? formatDateTime(logsState.newest) : "No events";
}
function renderSecretsStats() {
el.secretsTotalStat.textContent = String(secretsState.items.length);
el.secretsVisibleStat.textContent = `${secretsState.filtered.length} visible${secretsState.search ? " after search" : ""}`;
el.secretsLoadedStat.textContent = String(
Object.values(secretsState.values).filter((entry) => entry?.status === "loaded").length
);
el.secretsNewestStat.textContent = secretsState.newest ? formatDateTime(secretsState.newest) : "No secrets";
}
function renderS3Stats() {
el.s3TotalStat.textContent = String(s3State.objects.length);
el.s3VisibleStat.textContent = `${s3State.filtered.length} visible${s3State.search ? " after search" : ""}`;
el.s3BucketsStat.textContent = String(s3State.buckets.length);
el.s3NewestStat.textContent = s3State.newest ? formatDateTime(s3State.newest) : "No objects";
}
function renderFetch() {
if (state.loading) {
el.fetchStat.textContent = "Refreshing...";
el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`;
return;
}
if (state.error) {
el.fetchStat.textContent = "Needs attention";
el.fetchDetail.textContent = state.error;
return;
}
if (!state.updatedAt) {
el.fetchStat.textContent = "Idle";
el.fetchDetail.textContent = `Endpoint: ${config.endpoint}`;
return;
}
el.fetchStat.textContent = `${state.duration}ms`;
el.fetchDetail.textContent = `${state.parseErrors} parse error${state.parseErrors === 1 ? "" : "s"}. Endpoint: ${config.endpoint}`;
}
function renderLogsFetch() {
if (logsState.loading) {
el.logsFetchStat.textContent = "Refreshing...";
el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`;
return;
}
if (logsState.error) {
el.logsFetchStat.textContent = "Needs attention";
el.logsFetchDetail.textContent = logsState.error;
return;
}
if (!logsState.updatedAt) {
el.logsFetchStat.textContent = "Idle";
el.logsFetchDetail.textContent = `Endpoint: ${config.cloudWatchEndpoint} (${config.cloudWatchRegion})`;
return;
}
el.logsFetchStat.textContent = `${logsState.duration}ms`;
el.logsFetchDetail.textContent = `${logsState.group || "No group selected"}. Endpoint: ${config.cloudWatchEndpoint}`;
}
function renderSecretsFetch() {
if (secretsState.loading) {
el.secretsFetchStat.textContent = "Refreshing...";
el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`;
return;
}
if (secretsState.error) {
el.secretsFetchStat.textContent = "Needs attention";
el.secretsFetchDetail.textContent = secretsState.error;
return;
}
if (!secretsState.updatedAt) {
el.secretsFetchStat.textContent = "Idle";
el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`;
return;
}
el.secretsFetchStat.textContent = `${secretsState.duration}ms`;
el.secretsFetchDetail.textContent = `Endpoint: ${config.secretsEndpoint} (${config.secretsRegion})`;
}
function renderS3Fetch() {
if (s3State.loading) {
el.s3FetchStat.textContent = "Refreshing...";
el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`;
return;
}
if (s3State.error) {
el.s3FetchStat.textContent = "Needs attention";
el.s3FetchDetail.textContent = s3State.error;
return;
}
if (!s3State.updatedAt) {
el.s3FetchStat.textContent = "Idle";
el.s3FetchDetail.textContent = `Endpoint: ${config.s3Endpoint} (${config.s3Region})`;
return;
}
el.s3FetchStat.textContent = `${s3State.duration}ms`;
el.s3FetchDetail.textContent = `${s3State.bucket || "No bucket selected"}. Endpoint: ${config.s3Endpoint}`;
}
function renderStatus() {
el.statusChip.className = "status";
if (state.loading) {
el.statusChip.classList.add("warn");
el.statusChip.textContent = "Refreshing messages...";
return;
}
if (state.error) {
el.statusChip.classList.add("bad");
el.statusChip.textContent = `Refresh failed: ${state.error}`;
return;
}
if (!state.auto) {
el.statusChip.textContent = "Live refresh paused";
return;
}
if (document.hidden) {
el.statusChip.classList.add("warn");
el.statusChip.textContent = "Tab hidden, live refresh paused";
return;
}
if (!state.updatedAt) {
el.statusChip.textContent = "Waiting for first refresh...";
return;
}
const seconds = Math.max(0, Math.ceil((state.updatedAt + state.interval - Date.now()) / 1000));
el.statusChip.classList.add("ok");
el.statusChip.textContent = `Live refresh on, next check in ${seconds}s`;
}
function renderLogsStatus() {
el.logsStatusChip.className = "status";
if (logsState.loading) {
el.logsStatusChip.classList.add("warn");
el.logsStatusChip.textContent = "Refreshing logs...";
return;
}
if (logsState.error) {
el.logsStatusChip.classList.add("bad");
el.logsStatusChip.textContent = `Refresh failed: ${logsState.error}`;
return;
}
if (!logsState.auto) {
el.logsStatusChip.textContent = "Live refresh paused";
return;
}
if (document.hidden) {
el.logsStatusChip.classList.add("warn");
el.logsStatusChip.textContent = "Tab hidden, live refresh paused";
return;
}
if (!logsState.updatedAt) {
el.logsStatusChip.textContent = "Waiting for first refresh...";
return;
}
const seconds = Math.max(0, Math.ceil((logsState.updatedAt + logsState.interval - Date.now()) / 1000));
el.logsStatusChip.classList.add("ok");
el.logsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`;
}
function renderSecretsStatus() {
el.secretsStatusChip.className = "status";
if (secretsState.loading) {
el.secretsStatusChip.classList.add("warn");
el.secretsStatusChip.textContent = "Refreshing secrets...";
return;
}
if (secretsState.error) {
el.secretsStatusChip.classList.add("bad");
el.secretsStatusChip.textContent = `Refresh failed: ${secretsState.error}`;
return;
}
if (!secretsState.auto) {
el.secretsStatusChip.textContent = "Live refresh paused";
return;
}
if (document.hidden) {
el.secretsStatusChip.classList.add("warn");
el.secretsStatusChip.textContent = "Tab hidden, live refresh paused";
return;
}
if (!secretsState.updatedAt) {
el.secretsStatusChip.textContent = "Waiting for first refresh...";
return;
}
const seconds = Math.max(0, Math.ceil((secretsState.updatedAt + secretsState.interval - Date.now()) / 1000));
el.secretsStatusChip.classList.add("ok");
el.secretsStatusChip.textContent = `Live refresh on, next check in ${seconds}s`;
}
function renderS3Status() {
el.s3StatusChip.className = "status";
if (s3State.loading) {
el.s3StatusChip.classList.add("warn");
el.s3StatusChip.textContent = "Refreshing objects...";
return;
}
if (s3State.error) {
el.s3StatusChip.classList.add("bad");
el.s3StatusChip.textContent = `Refresh failed: ${s3State.error}`;
return;
}
if (!s3State.auto) {
el.s3StatusChip.textContent = "Live refresh paused";
return;
}
if (document.hidden) {
el.s3StatusChip.classList.add("warn");
el.s3StatusChip.textContent = "Tab hidden, live refresh paused";
return;
}
if (!s3State.updatedAt) {
el.s3StatusChip.textContent = "Waiting for first refresh...";
return;
}
const seconds = Math.max(0, Math.ceil((s3State.updatedAt + s3State.interval - Date.now()) / 1000));
el.s3StatusChip.classList.add("ok");
el.s3StatusChip.textContent = `Live refresh on, next check in ${seconds}s`;
}
function renderLiveClock() {
if (!state.updatedAt) {
el.updatedStat.textContent = "Not refreshed yet";
return;
}
el.updatedStat.textContent = `Updated ${formatRelative(state.updatedAt)} via ${state.source}`;
renderStatus();
}
function renderLogsLiveClock() {
if (!logsState.updatedAt) {
el.logsUpdatedStat.textContent = "Not refreshed yet";
return;
}
el.logsUpdatedStat.textContent = `Updated ${formatRelative(logsState.updatedAt)} via ${logsState.source}`;
renderLogsStatus();
}
function renderSecretsLiveClock() {
if (!secretsState.updatedAt) {
el.secretsUpdatedStat.textContent = "Not refreshed yet";
return;
}
el.secretsUpdatedStat.textContent = `Updated ${formatRelative(secretsState.updatedAt)} via ${secretsState.source}`;
renderSecretsStatus();
}
function renderS3LiveClock() {
if (!s3State.updatedAt) {
el.s3UpdatedStat.textContent = "Not refreshed yet";
return;
}
el.s3UpdatedStat.textContent = `Updated ${formatRelative(s3State.updatedAt)} via ${s3State.source}`;
renderS3Status();
}
function renderList() {
el.banner.hidden = !state.error;
el.banner.textContent = state.error ? `Refresh failed: ${state.error}` : "";
if (!state.filtered.length) {
el.list.innerHTML = "";
el.empty.hidden = false;
el.empty.textContent = state.messages.length
? "No messages match the current search."
: "No emails yet. Send one through LocalStack SES and refresh.";
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
return;
}
el.empty.hidden = true;
el.list.innerHTML = state.filtered.map(renderCard).join("");
bindCardToggles();
syncCardExpansion();
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
}
function renderLogsList() {
el.logsBanner.hidden = !logsState.error;
el.logsBanner.textContent = logsState.error ? `Refresh failed: ${logsState.error}` : "";
if (!logsState.group && !logsState.groups.length) {
el.logsList.innerHTML = "";
el.logsEmpty.hidden = false;
el.logsEmpty.textContent = "No CloudWatch log groups found in LocalStack yet.";
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
return;
}
if (!logsState.filtered.length) {
el.logsList.innerHTML = "";
el.logsEmpty.hidden = false;
el.logsEmpty.textContent = logsState.events.length
? "No log events match the current search."
: "No log events found for the selected group, stream, and time window.";
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
return;
}
el.logsEmpty.hidden = true;
el.logsList.innerHTML = logsState.filtered.map((event) => renderLogEvent(event)).join("");
bindLogToggles();
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
}
function renderSecretsList() {
el.secretsBanner.hidden = !secretsState.error;
el.secretsBanner.textContent = secretsState.error ? `Refresh failed: ${secretsState.error}` : "";
if (!secretsState.filtered.length) {
el.secretsList.innerHTML = "";
el.secretsEmpty.hidden = false;
el.secretsEmpty.textContent = secretsState.items.length
? "No secrets match the current search."
: "No Secrets Manager entries found in LocalStack yet.";
updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton);
return;
}
el.secretsEmpty.hidden = true;
el.secretsList.innerHTML = secretsState.filtered.map((secret) => renderSecretCard(secret)).join("");
bindSecretToggles();
syncSecretExpansion();
updatePaneTopButtonVisibility(el.secretsContentPane, el.secretsScrollToTopButton);
}
function renderS3List() {
el.s3Banner.hidden = !s3State.error;
el.s3Banner.textContent = s3State.error ? `Refresh failed: ${s3State.error}` : "";
if (!s3State.bucket && !s3State.buckets.length) {
el.s3List.innerHTML = "";
el.s3Empty.hidden = false;
el.s3Empty.textContent = "No S3 buckets found in LocalStack yet.";
updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton);
return;
}
if (!s3State.filtered.length) {
el.s3List.innerHTML = "";
el.s3Empty.hidden = false;
el.s3Empty.textContent = s3State.objects.length
? "No S3 objects match the current search."
: "No S3 objects found for the selected bucket and prefix.";
updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton);
return;
}
el.s3Empty.hidden = true;
el.s3List.innerHTML = s3State.filtered.map((object) => renderS3ObjectCard(object)).join("");
bindS3Toggles();
syncS3Expansion();
updatePaneTopButtonVisibility(el.s3ContentPane, el.s3ScrollToTopButton);
}
function renderLogEvent(event) {
const level = detectLogLevel(event);
const levelTag = level ? `<span class="tag ${level.className}">${escapeHtml(level.label)}</span>` : "";
return `
<details class="logEvent" data-id="${escapeHtml(event.id)}" ${logsState.openIds.has(event.id) ? "open" : ""}>
<summary class="logSummary">
<div class="logSummaryTop">
<div class="logMeta">
<span class="time">${escapeHtml(formatDateTime(event.timestamp))}</span>
<span class="tag logTag" title="${escapeHtml(event.logStreamName || "Unknown stream")}">${escapeHtml(event.logStreamName || "Unknown stream")}</span>
${levelTag}
</div>
<div class="logSummaryActions">
<button class="mini logCopyButton" type="button" data-log-action="copy" data-id="${escapeHtml(event.id)}">📋 Copy JSON</button>
<span class="tag">${escapeHtml(formatRelative(event.timestamp || Date.now()))}</span>
</div>
</div>
<p class="logPreview jsonSyntax">${renderLogPreviewContent(event)}</p>
</summary>
<div class="logBody ${logsState.wrapLines ? "" : "wrapOff"}">
<pre class="jsonSyntax">${renderLogBodyContent(event.message)}</pre>
</div>
</details>
`;
}
function renderSecretCard(secret) {
const valueState = secretsState.values[secret.id];
const tags = [];
if (secret.owningService) {
tags.push(`<span class="tag secretTag">${escapeHtml(secret.owningService)}</span>`);
}
if (secret.primaryRegion) {
tags.push(`<span class="tag secretTag">${escapeHtml(secret.primaryRegion)}</span>`);
}
if (secret.rotationEnabled) {
tags.push('<span class="tag secretTag">Rotation on</span>');
} else {
tags.push('<span class="tag">Rotation off</span>');
}
if (secret.tagCount) {
tags.push(`<span class="tag secretTag">${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}</span>`);
}
if (valueState?.status === "loaded") {
tags.push(`<span class="tag secretTag">${escapeHtml(valueState.label)}</span>`);
}
if (valueState?.status === "error") {
tags.push('<span class="tag bad">Value load failed</span>');
}
return `
<details class="card secretCard" data-id="${escapeHtml(secret.id)}">
<summary class="summary secretSummary">
<div class="top">
<div class="head">
<h2>🔐 ${escapeHtml(secret.name)}</h2>
<p class="meta">${escapeHtml(secret.description || secret.arn || "No description")}</p>
</div>
<span class="time">${escapeHtml(formatDateTime(secret.lastChangedDate || secret.createdDate))}</span>
</div>
<div class="tags">${tags.join("")}</div>
<p class="preview">${escapeHtml(buildSecretPreview(secret))}</p>
</summary>
<div class="body secretBody">
<dl class="grid">
${metaCard("Name", secret.name)}
${metaCard("ARN", secret.arn || "Not available")}
${metaCard("Description", secret.description || "None")}
${metaCard("Last changed", formatDateTime(secret.lastChangedDate || secret.createdDate))}
${metaCard("Created", formatDateTime(secret.createdDate))}
${metaCard("Last accessed", formatDateTime(secret.lastAccessedDate))}
${metaCard("Primary region", secret.primaryRegion || "Not set")}
${metaCard("Owning service", secret.owningService || "Not set")}
</dl>
<div class="secretValuePanel">
${renderSecretValuePanel(secret, valueState)}
</div>
</div>
</details>
`;
}
function renderS3ObjectCard(object) {
const previewState = s3State.previews[object.id];
const tags = [
`<span class="tag bucketTag">${escapeHtml(object.bucket)}</span>`,
`<span class="tag bucketTag">${escapeHtml(formatBytes(object.size))}</span>`
];
if (object.storageClass) {
tags.push(`<span class="tag bucketTag">${escapeHtml(object.storageClass)}</span>`);
}
if (previewState?.status === "loaded") {
tags.push(`<span class="tag bucketTag">${escapeHtml(previewState.previewType)}</span>`);
}
if (previewState?.status === "error") {
tags.push('<span class="tag bad">Preview failed</span>');
}
return `
<details class="card s3Card" data-id="${escapeHtml(object.id)}">
<summary class="summary s3Summary">
<div class="top">
<div class="head">
<h2>🪣 ${escapeHtml(object.key)}</h2>
<p class="meta">${escapeHtml(object.bucket)}${escapeHtml(formatBytes(object.size))}</p>
</div>
<span class="time">${escapeHtml(formatDateTime(object.lastModified))}</span>
</div>
<div class="tags">${tags.join("")}</div>
<p class="preview">${escapeHtml(buildS3Preview(object))}</p>
</summary>
<div class="body s3Body">
<div class="toolbar">
<div class="actions">
<button class="mini" type="button" data-s3-action="copy-key" data-id="${escapeHtml(object.id)}">📋 Copy key</button>
<button class="mini" type="button" data-s3-action="copy-uri" data-id="${escapeHtml(object.id)}">🔗 Copy URI</button>
<a class="mini attachmentLink" href="/api/s3/download?bucket=${encodeURIComponent(object.bucket)}&key=${encodeURIComponent(object.key)}">⬇️ Download</a>
</div>
</div>
<dl class="grid">
${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")}
</dl>
<div class="s3PreviewPanel">${renderS3PreviewPanel(object, previewState)}</div>
</div>
</details>
`;
}
function bindLogToggles() {
el.logsList.querySelectorAll(".logEvent").forEach((details) => {
details.addEventListener("toggle", () => {
const id = details.dataset.id;
if (!id) {
return;
}
if (details.open) {
logsState.openIds.add(id);
} else {
logsState.openIds.delete(id);
}
});
});
}
function bindSecretToggles() {
el.secretsList.querySelectorAll(".secretCard").forEach((details) => {
details.addEventListener("toggle", () => {
const id = details.dataset.id;
if (!id) {
return;
}
if (details.open) {
secretsState.openIds.add(id);
ensureSecretValue(id, { force: false });
} else {
secretsState.openIds.delete(id);
}
});
});
}
function bindS3Toggles() {
el.s3List.querySelectorAll(".s3Card").forEach((details) => {
details.addEventListener("toggle", () => {
const id = details.dataset.id;
if (!id) {
return;
}
if (details.open) {
s3State.openIds.add(id);
ensureS3Preview(id, { force: false });
} else {
s3State.openIds.delete(id);
}
});
});
}
function bindCardToggles() {
el.list.querySelectorAll(".card").forEach((details) => {
details.addEventListener("toggle", () => {
const id = details.dataset.id;
if (!id) {
return;
}
if (details.open) {
state.openIds.add(id);
} else {
state.openIds.delete(id);
}
hydrate(details, getMessage(id));
});
});
}
function renderCard(message) {
const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text");
const tags = [];
if (state.newIds.has(message.id)) {
tags.push('<span class="tag new">New</span>');
}
if (message.attachmentCount) {
tags.push(
`<span class="tag">${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"}</span>`
);
}
tags.push(`<span class="tag">${message.hasHtml ? "HTML" : "Text only"}</span>`);
if (message.parseError) {
tags.push('<span class="tag bad">Parse issue</span>');
}
return `
<details class="card ${state.newIds.has(message.id) ? "new" : ""}" data-id="${escapeHtml(message.id)}">
<summary class="summary">
<div class="top">
<div class="head">
<h2>${escapeHtml(message.subject)}</h2>
<p class="meta">${escapeHtml(message.from)} to ${escapeHtml(message.to)}</p>
</div>
<span class="time">${escapeHtml(formatDateTime(message.timestamp))}</span>
</div>
<div class="tags">${tags.join("")}</div>
<p class="preview">${escapeHtml(message.preview)}</p>
</summary>
<div class="body">
<div class="toolbar">
<div class="tabs">
${
message.hasHtml
? `<button class="tab ${view === "rendered" ? "active" : ""}" type="button" data-action="view" data-view="rendered" data-id="${escapeHtml(message.id)}">Rendered</button>`
: ""
}
<button class="tab ${view === "text" ? "active" : ""}" type="button" data-action="view" data-view="text" data-id="${escapeHtml(message.id)}">Text</button>
<button class="tab ${view === "raw" ? "active" : ""}" type="button" data-action="view" data-view="raw" data-id="${escapeHtml(message.id)}">Raw</button>
</div>
<div class="actions">
<button class="mini" type="button" data-action="copy-raw" data-id="${escapeHtml(message.id)}">📋 Copy raw</button>
</div>
</div>
<dl class="grid">
${metaCard("From", message.from)}
${metaCard("To", message.to)}
${metaCard("Reply-To", message.replyTo || "None")}
${metaCard("Sent", formatDateTime(message.timestamp))}
${metaCard("Region", message.region || "Unknown region")}
${metaCard("LocalStack Id", message.id)}
${metaCard("Message-Id", message.messageId || "Not available")}
${metaCard("Raw size", formatBytes(message.rawSizeBytes))}
${message.parseError ? metaCard("Parse error", message.parseError) : ""}
</dl>
${
message.attachments?.length
? `<div class="attachments">${message.attachments
.map((attachment) => {
const size = attachment.size ? `, ${formatBytes(attachment.size)}` : "";
const icon = resolveAttachmentIcon(attachment);
return `<a class="attachment attachmentLink" href="/api/messages/${encodeURIComponent(message.id)}/attachments/${attachment.index}" download="${escapeHtml(attachment.filename)}" title="Download ${escapeHtml(attachment.filename)}">${icon} ${escapeHtml(attachment.filename)} (${escapeHtml(attachment.contentType)}${escapeHtml(size)})</a>`;
})
.join("")}</div>`
: ""
}
<div class="panel">${renderPanel(message, view)}</div>
</div>
</details>
`;
}
function renderPanel(message, view) {
if (view === "rendered" && message.hasHtml) {
return `<iframe title="${escapeHtml(message.subject)}" data-frame sandbox="" referrerpolicy="no-referrer"></iframe>`;
}
if (view === "raw") {
const raw = state.raw[message.id];
if (!raw) {
return `<div class="placeholder">Raw MIME source is loaded on demand.<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-action="load-raw" data-id="${escapeHtml(message.id)}">Load raw source</button></div></div>`;
}
if (raw.status === "loading") {
return '<div class="placeholder">Loading raw source...</div>';
}
if (raw.status === "error") {
return `<div class="inlineError">Unable to load raw source: ${escapeHtml(raw.error)}<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-action="load-raw" data-id="${escapeHtml(message.id)}">Retry</button></div></div>`;
}
return `<pre>${escapeHtml(raw.value)}</pre>`;
}
return `<pre>${escapeHtml(message.textContent || "No plain-text content available for this message.")}</pre>`;
}
function renderSecretValuePanel(secret, valueState) {
if (!valueState) {
return `<div class="placeholder">Secret values are loaded on demand.<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-secret-action="load-value" data-id="${escapeHtml(secret.id)}">Load value</button></div></div>`;
}
if (valueState.status === "loading") {
return '<div class="placeholder">Loading secret value...</div>';
}
if (valueState.status === "error") {
return `<div class="inlineError">Unable to load secret value: ${escapeHtml(valueState.error)}<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-secret-action="reload-value" data-id="${escapeHtml(secret.id)}">Retry</button></div></div>`;
}
const revealed = secretsState.revealedIds.has(secret.id);
const maskedHtml = escapeHtml(maskSecretValue(valueState.copyValue));
return `
<div class="toolbar">
<div class="tags">
<span class="tag secretTag">${escapeHtml(valueState.label)}</span>
${
valueState.versionId
? `<span class="tag secretTag">Version ${escapeHtml(valueState.versionId.slice(0, 8))}</span>`
: ""
}
${
valueState.versionStages.length
? `<span class="tag secretTag">${escapeHtml(valueState.versionStages.join(", "))}</span>`
: ""
}
</div>
<div class="actions">
<button class="mini" type="button" data-secret-action="toggle-reveal" data-id="${escapeHtml(secret.id)}">${revealed ? "🙈 Hide" : "👁️ Reveal"}</button>
<button class="mini" type="button" data-secret-action="copy-value" data-id="${escapeHtml(secret.id)}">📋 Copy value</button>
<button class="mini" type="button" data-secret-action="copy-env" data-id="${escapeHtml(secret.id)}">🧪 Copy env</button>
<button class="mini" type="button" data-secret-action="copy-name" data-id="${escapeHtml(secret.id)}">🏷️ Copy name</button>
<button class="mini" type="button" data-secret-action="copy-arn" data-id="${escapeHtml(secret.id)}">🔗 Copy ARN</button>
<button class="mini" type="button" data-secret-action="reload-value" data-id="${escapeHtml(secret.id)}">🔄 Reload</button>
</div>
</div>
<pre class="${revealed && valueState.isJson ? "jsonSyntax" : ""}">${revealed ? valueState.displayHtml : maskedHtml}</pre>
`;
}
function renderS3PreviewPanel(object, previewState) {
if (!previewState) {
return `<div class="placeholder">Object previews load on demand.<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-s3-action="load-preview" data-id="${escapeHtml(object.id)}">Load preview</button></div></div>`;
}
if (previewState.status === "loading") {
return '<div class="placeholder">Loading object preview...</div>';
}
if (previewState.status === "error") {
return `<div class="inlineError">Unable to load object preview: ${escapeHtml(previewState.error)}<div class="actions" style="margin-top:12px"><button class="mini" type="button" data-s3-action="reload-preview" data-id="${escapeHtml(object.id)}">Retry</button></div></div>`;
}
const truncatedTag = previewState.truncated ? '<span class="tag bucketTag">Preview truncated</span>' : "";
let previewContent = `<pre>No inline preview available for this object type.</pre>`;
if (previewState.previewType === "image" && previewState.imageDataUrl) {
previewContent = `<img class="s3PreviewImage" alt="${escapeHtml(object.key)}" src="${previewState.imageDataUrl}">`;
} else if (previewState.previewType === "json") {
previewContent = `<pre class="jsonSyntax">${highlightJsonText(prettyJsonOrText(previewState.previewText))}</pre>`;
} else if (previewState.previewType === "text" || previewState.previewType === "html") {
previewContent = `<pre>${escapeHtml(previewState.previewText || "No preview text available.")}</pre>`;
}
return `
<div class="toolbar">
<div class="tags">
<span class="tag bucketTag">${escapeHtml(previewState.previewType)}</span>
${truncatedTag}
${previewState.contentType ? `<span class="tag bucketTag">${escapeHtml(previewState.contentType)}</span>` : ""}
</div>
<div class="actions">
<button class="mini" type="button" data-s3-action="reload-preview" data-id="${escapeHtml(object.id)}">🔄 Reload</button>
<a class="mini attachmentLink" href="/api/s3/download?bucket=${encodeURIComponent(object.bucket)}&key=${encodeURIComponent(object.key)}">⬇️ Download</a>
</div>
</div>
${previewContent}
`;
}
function metaCard(label, value) {
return `<div class="metaCard"><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`;
}
function syncCardExpansion() {
const applyCardState = () => {
el.list.querySelectorAll(".card").forEach((details) => {
const id = details.dataset.id;
const shouldOpen = Boolean(id && state.openIds.has(id));
if (shouldOpen && !details.open) {
details.open = true;
}
if (!shouldOpen && details.open) {
details.open = false;
return;
}
if (shouldOpen) {
hydrate(details, getMessage(id));
}
});
};
applyCardState();
window.requestAnimationFrame(applyCardState);
}
function syncSecretExpansion() {
const applySecretState = () => {
el.secretsList.querySelectorAll(".secretCard").forEach((details) => {
const id = details.dataset.id;
const shouldOpen = Boolean(id && secretsState.openIds.has(id));
if (shouldOpen && !details.open) {
details.open = true;
}
if (!shouldOpen && details.open) {
details.open = false;
return;
}
if (shouldOpen) {
ensureSecretValue(id, { force: false });
}
});
};
applySecretState();
window.requestAnimationFrame(applySecretState);
}
function syncS3Expansion() {
const applyS3State = () => {
el.s3List.querySelectorAll(".s3Card").forEach((details) => {
const id = details.dataset.id;
const shouldOpen = Boolean(id && s3State.openIds.has(id));
if (shouldOpen && !details.open) {
details.open = true;
}
if (!shouldOpen && details.open) {
details.open = false;
return;
}
if (shouldOpen) {
ensureS3Preview(id, { force: false });
}
});
};
applyS3State();
window.requestAnimationFrame(applyS3State);
}
function resolveAttachmentIcon(attachment) {
const filename = String(attachment?.filename || "").toLowerCase();
const contentType = String(attachment?.contentType || "").toLowerCase();
if (filename.endsWith(".pdf") || contentType.includes("pdf")) {
return "📄";
}
if (
[".doc", ".docx", ".txt", ".rtf", ".md"].some((extension) => filename.endsWith(extension)) ||
contentType.includes("word") ||
contentType.startsWith("text/")
) {
return "📝";
}
if (
[".xls", ".xlsx", ".csv"].some((extension) => filename.endsWith(extension)) ||
contentType.includes("sheet") ||
contentType.includes("csv")
) {
return "📊";
}
if (
filename.endsWith(".json") ||
filename.endsWith(".xml") ||
filename.endsWith(".yaml") ||
filename.endsWith(".yml")
) {
return "🧾";
}
if (contentType.startsWith("image/")) {
return "🖼️";
}
if (contentType.startsWith("audio/")) {
return "🎵";
}
if (contentType.startsWith("video/")) {
return "🎬";
}
if (
[".zip", ".rar", ".7z", ".tar", ".gz"].some((extension) => filename.endsWith(extension)) ||
contentType.includes("zip") ||
contentType.includes("compressed")
) {
return "🗜️";
}
return "📎";
}
function hydrate(details, message) {
if (!details || !details.open || !message) {
return;
}
const view = state.views[message.id] || (message.hasHtml ? "rendered" : "text");
if (view !== "rendered" || !message.hasHtml) {
return;
}
const iframe = details.querySelector("[data-frame]");
if (iframe) {
iframe.referrerPolicy = "no-referrer";
iframe.sandbox = "";
iframe.srcdoc = message.renderedHtml || "";
}
}
function getMessage(id) {
return state.messages.find((message) => message.id === id);
}
function getLogEvent(id) {
return logsState.events.find((event) => event.id === id);
}
function getSecret(id) {
return secretsState.items.find((secret) => secret.id === id);
}
function getS3Object(id) {
return s3State.objects.find((object) => object.id === id);
}
async function loadRaw(id) {
if (state.raw[id]?.status === "loaded") {
return state.raw[id].value;
}
if (state.raw[id]?.status === "loading") {
return null;
}
state.raw[id] = { status: "loading" };
renderList();
try {
const response = await fetch(`/api/messages/${encodeURIComponent(id)}/raw`, { cache: "no-store" });
if (!response.ok) {
throw new Error((await response.text()) || `Request failed with ${response.status}`);
}
const value = await response.text();
state.raw[id] = { status: "loaded", value };
return value;
} catch (error) {
state.raw[id] = { status: "error", error: error.message || "Unknown raw load error" };
setStatus("Could not load the raw message source.", "bad");
return null;
} finally {
renderList();
}
}
async function ensureSecretValue(id, options = {}) {
const { force = false } = options;
if (!id) {
return null;
}
if (!force && secretsState.values[id]?.status === "loaded") {
return secretsState.values[id];
}
if (secretsState.values[id]?.status === "loading") {
return null;
}
secretsState.values[id] = { status: "loading" };
renderSecretsAll();
try {
const response = await fetch(`/api/secrets/value?id=${encodeURIComponent(id)}`, { cache: "no-store" });
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
const secretString = typeof payload.secretString === "string" ? payload.secretString : "";
const secretBinary = typeof payload.secretBinary === "string" ? payload.secretBinary : "";
const parsedString = secretString ? tryParseJsonText(secretString) : { ok: false, value: null };
const entry = {
status: "loaded",
label: secretBinary ? "Binary" : parsedString.ok ? "JSON" : secretString ? "Text" : "Empty",
copyValue: secretBinary
? secretBinary
: parsedString.ok
? JSON.stringify(parsedString.value, null, 2)
: secretString || "No secret value.",
displayHtml: secretBinary
? escapeHtml(secretBinary)
: parsedString.ok
? highlightJsonText(JSON.stringify(parsedString.value, null, 2))
: escapeHtml(secretString || "No secret value."),
isJson: parsedString.ok,
isBinary: Boolean(secretBinary),
versionId: payload.versionId || "",
versionStages: Array.isArray(payload.versionStages) ? payload.versionStages : [],
createdDate: payload.createdDate || "",
arn: payload.arn || "",
name: payload.name || ""
};
secretsState.values[id] = entry;
return entry;
} catch (error) {
secretsState.values[id] = {
status: "error",
error: error.message || "Unknown secret value error"
};
setSecretsStatus("Could not load the secret value.", "bad");
return null;
} finally {
renderSecretsAll();
}
}
async function ensureS3Preview(id, options = {}) {
const { force = false } = options;
if (!id) {
return null;
}
if (!force && s3State.previews[id]?.status === "loaded") {
return s3State.previews[id];
}
if (s3State.previews[id]?.status === "loading") {
return null;
}
const object = getS3Object(id);
if (!object) {
return null;
}
s3State.previews[id] = { status: "loading" };
renderS3All();
try {
const response = await fetch(
`/api/s3/object?bucket=${encodeURIComponent(object.bucket)}&key=${encodeURIComponent(object.key)}`,
{ cache: "no-store" }
);
if (!response.ok) {
const payload = await safeJson(response);
throw new Error(payload?.details || payload?.error || `Request failed with ${response.status}`);
}
const payload = await response.json();
const entry = {
status: "loaded",
previewType: payload.previewType || "binary",
previewText: payload.previewText || "",
imageDataUrl: payload.imageDataUrl || "",
contentType: payload.contentType || "",
contentLength: payload.contentLength || 0,
truncated: Boolean(payload.truncated),
metadata: payload.metadata || {}
};
s3State.previews[id] = entry;
return entry;
} catch (error) {
s3State.previews[id] = {
status: "error",
error: error.message || "Unknown S3 preview error"
};
setS3Status("Could not load the S3 object preview.", "bad");
return null;
} finally {
renderS3All();
}
}
async function copyText(value) {
try {
await navigator.clipboard.writeText(value);
} catch {
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}
}
function setStatus(message, tone) {
el.statusChip.className = "status";
if (tone) {
el.statusChip.classList.add(tone);
}
el.statusChip.textContent = message;
}
function setLogsStatus(message, tone) {
el.logsStatusChip.className = "status";
if (tone) {
el.logsStatusChip.classList.add(tone);
}
el.logsStatusChip.textContent = message;
}
function setSecretsStatus(message, tone) {
el.secretsStatusChip.className = "status";
if (tone) {
el.secretsStatusChip.classList.add(tone);
}
el.secretsStatusChip.textContent = message;
}
function setS3Status(message, tone) {
el.s3StatusChip.className = "status";
if (tone) {
el.s3StatusChip.classList.add(tone);
}
el.s3StatusChip.textContent = message;
}
function getInitialPanel() {
const storedPanel = readStoredValue(PANEL_STORAGE_KEY);
return ["emails", "logs", "secrets", "s3"].includes(storedPanel) ? storedPanel : "emails";
}
function getInitialTheme() {
const storedTheme = readStoredValue(THEME_STORAGE_KEY);
if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light";
}
function applyTheme(theme) {
const nextTheme = theme === "dark" ? "dark" : "light";
appState.theme = nextTheme;
document.body.dataset.theme = nextTheme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
} catch {}
renderThemeToggle();
}
function renderThemeToggle() {
if (!el.themeToggle) {
return;
}
const isDark = appState.theme === "dark";
el.themeToggle.textContent = isDark ? "🌙 Dark theme" : "☀️ Light theme";
el.themeToggle.setAttribute("aria-pressed", isDark ? "true" : "false");
el.themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme");
el.themeToggle.title = isDark ? "Switch to light theme" : "Switch to dark theme";
}
function persistPanel() {
writeStoredValue(PANEL_STORAGE_KEY, appState.panel);
}
function persistEmailPreferences() {
writeStoredJson(EMAIL_PREFERENCES_STORAGE_KEY, {
search: state.search,
auto: state.auto,
interval: state.interval
});
}
function persistLogPreferences() {
writeStoredJson(LOG_PREFERENCES_STORAGE_KEY, {
group: logsState.group,
stream: logsState.stream,
search: logsState.search,
auto: logsState.auto,
interval: logsState.interval,
windowMs: logsState.windowMs,
limit: logsState.limit,
wrapLines: logsState.wrapLines,
tailNewest: logsState.tailNewest
});
}
function persistSecretPreferences() {
writeStoredJson(SECRET_PREFERENCES_STORAGE_KEY, {
search: secretsState.search,
auto: secretsState.auto,
interval: secretsState.interval
});
}
function persistS3Preferences() {
writeStoredJson(S3_PREFERENCES_STORAGE_KEY, {
bucket: s3State.bucket,
prefix: s3State.prefix,
search: s3State.search,
auto: s3State.auto,
interval: s3State.interval
});
}
function resetSavedState() {
[
THEME_STORAGE_KEY,
PANEL_STORAGE_KEY,
EMAIL_PREFERENCES_STORAGE_KEY,
LOG_PREFERENCES_STORAGE_KEY,
SECRET_PREFERENCES_STORAGE_KEY,
S3_PREFERENCES_STORAGE_KEY
].forEach((key) => {
try {
window.localStorage.removeItem(key);
} catch {}
});
window.location.reload();
}
function getStoredPreferences(key) {
try {
const rawValue = window.localStorage.getItem(key);
if (!rawValue) {
return null;
}
const parsedValue = JSON.parse(rawValue);
return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) ? parsedValue : null;
} catch {
return null;
}
}
function readStoredValue(key) {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function writeStoredValue(key, value) {
try {
window.localStorage.setItem(key, String(value));
} catch {}
}
function writeStoredJson(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {}
}
function getStoredText(value, fallback = "") {
return typeof value === "string" ? value : fallback;
}
function getStoredBoolean(value, fallback) {
return typeof value === "boolean" ? value : fallback;
}
function getStoredNumber(value, allowedValues, fallback) {
const normalizedValue = Number(value);
return Number.isFinite(normalizedValue) && allowedValues.includes(normalizedValue) ? normalizedValue : fallback;
}
function scrollPaneToTop(element) {
if (!element) {
return;
}
element.scrollTo({ top: 0, behavior: "smooth" });
}
function updatePaneTopButtonVisibility(pane, button) {
if (!pane || !button) {
return;
}
button.classList.toggle("visible", pane.scrollTop > 140);
}
function formatDateTime(value) {
if (!value) {
return "Unknown time";
}
const date = new Date(value);
return Number.isNaN(date.getTime())
? "Unknown time"
: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function formatRelative(timestampMs) {
const seconds = Math.max(1, Math.round((Date.now() - timestampMs) / 1000));
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
}
function formatBytes(value) {
if (!value) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB"];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${index === 0 ? size : size.toFixed(1)} ${units[index]}`;
}
function formatLogMessage(message) {
const value = String(message || "").trim();
if (!value) {
return "No log payload.";
}
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
function buildSecretPreview(secret) {
const fragments = [];
if (secret.description) {
fragments.push(secret.description);
}
if (secret.owningService) {
fragments.push(`Service: ${secret.owningService}`);
}
if (secret.primaryRegion) {
fragments.push(`Region: ${secret.primaryRegion}`);
}
if (secret.tagCount) {
fragments.push(`${secret.tagCount} tag${secret.tagCount === 1 ? "" : "s"}`);
}
if (!fragments.length) {
fragments.push("No description or tags yet.");
}
return fragments.join(" • ");
}
function toggleSecretReveal(id) {
if (secretsState.revealedIds.has(id)) {
secretsState.revealedIds.delete(id);
} else {
secretsState.revealedIds.add(id);
}
renderSecretsAll();
}
function maskSecretValue(value) {
const source = String(value || "");
if (!source) {
return "Secret is loaded but empty.";
}
const lines = source.split("\n");
return lines.map((line) => "•".repeat(Math.max(12, Math.min(line.length || 0, 48)))).join("\n");
}
function buildS3Preview(object) {
const fragments = [];
if (object.storageClass) {
fragments.push(object.storageClass);
}
fragments.push(formatBytes(object.size));
if (object.etag) {
fragments.push(`ETag ${object.etag.slice(0, 12)}`);
}
return fragments.join(" • ");
}
function prettyJsonOrText(value) {
const parsed = tryParseJsonText(value);
return parsed.ok ? JSON.stringify(parsed.value, null, 2) : String(value || "");
}
function detectLogLevel(event) {
const parsed = tryParseJsonText(event?.message);
const candidates = parsed.ok
? [parsed.value?.level, parsed.value?.severity, parsed.value?.logLevel, parsed.value?.status, parsed.value?.lvl]
: [String(event?.message || "").match(/\b(error|warn|warning|info|debug|trace|fatal)\b/i)?.[0] || ""];
const normalized = String(candidates.find(Boolean) || "").toLowerCase();
if (["fatal", "error", "critical"].includes(normalized)) {
return { label: normalized.toUpperCase(), className: "levelError" };
}
if (["warn", "warning"].includes(normalized)) {
return { label: "WARN", className: "levelWarn" };
}
if (["info", "notice"].includes(normalized)) {
return { label: normalized.toUpperCase(), className: "levelInfo" };
}
if (["debug", "trace"].includes(normalized)) {
return { label: normalized.toUpperCase(), className: "levelDebug" };
}
return null;
}
function renderLogPreviewContent(event) {
const parsedLog = tryParseJsonText(event?.message);
if (!parsedLog.ok) {
return escapeHtml(event?.preview || "No preview available.");
}
const compactJson = JSON.stringify(parsedLog.value);
const previewText = compactJson.length > 220 ? `${compactJson.slice(0, 217)}...` : compactJson;
return highlightJsonText(previewText);
}
function renderLogBodyContent(message) {
const parsedLog = tryParseJsonText(message);
if (!parsedLog.ok) {
return escapeHtml(formatLogMessage(message));
}
return highlightJsonText(JSON.stringify(parsedLog.value, null, 2));
}
function tryParseJsonText(message) {
const value = String(message || "").trim();
if (!value) {
return { ok: false, value: null };
}
try {
return { ok: true, value: JSON.parse(value) };
} catch {
return { ok: false, value: null };
}
}
function highlightJsonText(value) {
const source = String(value ?? "");
const tokenRegex =
/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?)/g;
let html = "";
let lastIndex = 0;
for (const match of source.matchAll(tokenRegex)) {
const [token] = match;
const index = match.index ?? 0;
let className = "jsonNumber";
html += escapeHtml(source.slice(lastIndex, index));
if (token.endsWith(":")) {
className = "jsonKey";
} else if (token === "true" || token === "false") {
className = "jsonBoolean";
} else if (token === "null") {
className = "jsonNull";
} else if (token.startsWith('"')) {
className = "jsonString";
}
html += `<span class="${className}">${escapeHtml(token)}</span>`;
lastIndex = index + token.length;
}
html += escapeHtml(source.slice(lastIndex));
return html;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function safeJson(response) {
try {
return await response.json();
} catch {
return null;
}
}
}
app.listen(PORT, () => {
console.log(`LocalStack inspector is running on http://localhost:${PORT}`);
console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`);
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})`);
});