2207 lines
78 KiB
JavaScript
2207 lines
78 KiB
JavaScript
import express from "express";
|
|
import fetch from "node-fetch";
|
|
import {
|
|
CloudWatchLogsClient,
|
|
DescribeLogGroupsCommand,
|
|
DescribeLogStreamsCommand,
|
|
FilterLogEventsCommand
|
|
} from "@aws-sdk/client-cloudwatch-logs";
|
|
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 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
|
|
});
|
|
|
|
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
|
|
},
|
|
port: PORT,
|
|
defaultRefreshMs: DEFAULT_REFRESH_MS
|
|
});
|
|
});
|
|
|
|
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
|
|
});
|
|
}
|
|
});
|
|
|
|
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 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 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,
|
|
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 id="workspaceTabs" class="workspaceTabs" role="tablist" aria-label="Inspector views">
|
|
<button class="workspaceTab active" type="button" data-panel="emails" aria-pressed="true">SES Emails</button>
|
|
<button class="workspaceTab" type="button" data-panel="logs" aria-pressed="false">CloudWatch Logs</button>
|
|
</div>
|
|
<button id="themeToggle" class="ghost themeToggle" type="button" aria-pressed="false">Theme: Light</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">↑</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>
|
|
</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">↑</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;--ink:#1f2933;--muted:#607080;--line:rgba(31,41,51,.12);--card-line:rgba(207,109,60,.24);--log-line:rgba(48,113,169,.22);--accent:#cf6d3c;--accent-soft:rgba(207,109,60,.1);--info:#3071a9;--info-soft:rgba(48,113,169,.1);--ok:#1f8f65;--warn:#9d5f00;--bad:#b33a3a;--shadow:0 12px 28px rgba(35,43,53,.08);--card-shadow:0 18px 34px rgba(122,78,34,.12);--log-shadow:0 16px 32px rgba(48,113,169,.12);}
|
|
*{box-sizing:border-box}
|
|
html,body{margin:0;min-height:100%}
|
|
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;gap:10px;max-width:1360px;align-content:start;margin:0 auto;padding:14px}
|
|
.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}
|
|
.helper{margin:0;color:var(--muted);font-size:.89rem}
|
|
.workspaceTabs{display:flex;flex-wrap:wrap;gap:6px;padding:4px;border-radius:999px;background:rgba(31,41,51,.05)}
|
|
.workspaceTab,.primary,.ghost,.mini,.tab{border-radius:999px;border:1px solid transparent;transition:transform .12s ease,background-color .12s ease,border-color .12s ease}
|
|
.workspaceTab{min-height:32px;padding:0 12px;background:transparent;color:var(--muted);font-weight:700}
|
|
.workspaceTab.active{background:#fff;border-color:rgba(31,41,51,.08);color:var(--ink)}
|
|
.themeToggle{white-space:nowrap}
|
|
.workspacePanel{display:grid;gap:6px}
|
|
.workspacePanel[hidden]{display:none}
|
|
.toolControls{display:grid;gap:8px}
|
|
.contentPane{height:clamp(360px,50vh,720px);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}
|
|
.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)}
|
|
.logSummary{list-style:none;display:grid;gap:7px;padding:10px 12px;cursor:pointer}
|
|
.logSummary::-webkit-details-marker{display:none}
|
|
.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}
|
|
.logTag{background:var(--info-soft);color:var(--info);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)}
|
|
.logCodeWrap{position:relative}
|
|
.logCopyButton{position:absolute;top:10px;right:10px;z-index:1;backdrop-filter:blur(12px);box-shadow:0 8px 18px rgba(31,41,51,.16)}
|
|
.logBody pre{border-radius:12px;border:1px solid rgba(48,113,169,.14);padding:42px 12px 12px;background:linear-gradient(180deg,rgba(48,113,169,.04),transparent 140px),#fff}
|
|
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"] .workspaceTabs{background:rgba(148,163,184,.12)}
|
|
body[data-theme="dark"] .workspaceTab,
|
|
body[data-theme="dark"] .tab{color:#aab8c8}
|
|
body[data-theme="dark"] .workspaceTab.active,
|
|
body[data-theme="dark"] .tab.active,
|
|
body[data-theme="dark"] .ghost,
|
|
body[data-theme="dark"] .mini,
|
|
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"] .workspaceTab.active,
|
|
body[data-theme="dark"] .tab.active{border-color:rgba(148,163,184,.18)}
|
|
body[data-theme="dark"] .summary{background:linear-gradient(180deg,rgba(58,40,31,.88),rgba(45,33,28,.96))}
|
|
body[data-theme="dark"] .body{background:#211a17;border-top-color:rgba(207,109,60,.18)}
|
|
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"] .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"] .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"] .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"] .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"] .workspaceTab,
|
|
body[data-theme="dark"] .tab{color:#aab8c8}
|
|
body[data-theme="dark"] .paneTopButton{border-color:rgba(255,255,255,.18);background:rgba(8,12,18,.58);color:#edf2f7}
|
|
body[data-theme="dark"] .paneTopButton.visible:hover{background:rgba(8,12,18,.8)}
|
|
@media (max-width:1080px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
@media (max-width:720px){.page{padding:12px}.heroShell,.heroTopRow,.toolbar,.row{align-items:stretch}.stats{grid-template-columns:1fr}.heroTopRow{justify-content:stretch;flex-basis:100%}.workspaceTab,.primary,.ghost,.chip,.themeToggle{width:100%;justify-content:center}.logSummaryTop{align-items:flex-start}.logCopyButton{top:8px;right:8px}.contentPane{height:clamp(300px,48vh,560px)}iframe{min-height:420px}}
|
|
`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function clientApp(config) {
|
|
const appState = {
|
|
panel: "emails",
|
|
logsReady: false,
|
|
theme: getInitialTheme()
|
|
};
|
|
const THEME_STORAGE_KEY = "localstack-inspector-theme";
|
|
|
|
const state = {
|
|
messages: [],
|
|
filtered: [],
|
|
search: "",
|
|
auto: true,
|
|
interval: 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: config.defaultLogGroup || "",
|
|
stream: "",
|
|
search: "",
|
|
auto: true,
|
|
interval: config.defaultRefreshMs,
|
|
windowMs: config.defaultLogWindowMs,
|
|
limit: config.defaultLogLimit,
|
|
loading: false,
|
|
error: "",
|
|
updatedAt: 0,
|
|
source: "initial",
|
|
duration: 0,
|
|
newest: 0,
|
|
searchedLogStreams: 0,
|
|
openIds: new Set(),
|
|
hasInteracted: false,
|
|
listSignature: ""
|
|
};
|
|
|
|
const el = {
|
|
workspaceTabs: document.getElementById("workspaceTabs"),
|
|
themeToggle: document.getElementById("themeToggle"),
|
|
emailsPanel: document.getElementById("emailsPanel"),
|
|
logsPanel: document.getElementById("logsPanel"),
|
|
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"),
|
|
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")
|
|
};
|
|
|
|
el.intervalSelect.value = String(config.defaultRefreshMs);
|
|
el.logsIntervalSelect.value = String(config.defaultRefreshMs);
|
|
el.logsWindowSelect.value = String(config.defaultLogWindowMs);
|
|
el.logsLimitSelect.value = String(config.defaultLogLimit);
|
|
applyTheme(appState.theme);
|
|
wire();
|
|
renderWorkspace();
|
|
renderAll();
|
|
renderLogsAll();
|
|
refreshMessages("initial");
|
|
window.setInterval(() => {
|
|
renderLiveClock();
|
|
renderLogsLiveClock();
|
|
}, 1000);
|
|
|
|
function wire() {
|
|
el.workspaceTabs.addEventListener("click", async (event) => {
|
|
const button = event.target.closest("button[data-panel]");
|
|
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
await setPanel(button.dataset.panel);
|
|
});
|
|
|
|
el.themeToggle.addEventListener("click", () => {
|
|
applyTheme(appState.theme === "dark" ? "light" : "dark");
|
|
});
|
|
|
|
el.refreshButton.addEventListener("click", () => refreshMessages("manual"));
|
|
|
|
el.autoToggle.addEventListener("change", () => {
|
|
state.auto = el.autoToggle.checked;
|
|
scheduleRefresh();
|
|
renderStatus();
|
|
});
|
|
|
|
el.intervalSelect.addEventListener("change", () => {
|
|
state.interval = Number(el.intervalSelect.value) || config.defaultRefreshMs;
|
|
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));
|
|
renderList();
|
|
});
|
|
|
|
el.collapseAllButton.addEventListener("click", () => {
|
|
state.openIds.clear();
|
|
renderList();
|
|
});
|
|
|
|
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;
|
|
scheduleLogsRefresh();
|
|
renderLogsStatus();
|
|
});
|
|
|
|
el.logsIntervalSelect.addEventListener("change", () => {
|
|
logsState.interval = Number(el.logsIntervalSelect.value) || config.defaultRefreshMs;
|
|
scheduleLogsRefresh();
|
|
renderLogsStatus();
|
|
});
|
|
|
|
el.logsWindowSelect.addEventListener("change", () => {
|
|
logsState.windowMs = Number(el.logsWindowSelect.value) || config.defaultLogWindowMs;
|
|
refreshLogs("window");
|
|
});
|
|
|
|
el.logsLimitSelect.addEventListener("change", () => {
|
|
logsState.limit = Number(el.logsLimitSelect.value) || config.defaultLogLimit;
|
|
refreshLogs("limit");
|
|
});
|
|
|
|
el.logsSearchInput.addEventListener("input", (event) => {
|
|
logsState.search = event.target.value;
|
|
applyLogsFilter();
|
|
});
|
|
|
|
el.logsClearSearchButton.addEventListener("click", () => {
|
|
logsState.search = "";
|
|
el.logsSearchInput.value = "";
|
|
applyLogsFilter();
|
|
});
|
|
|
|
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 = "";
|
|
await refreshLogStreams();
|
|
await refreshLogs("group");
|
|
});
|
|
|
|
el.logsStreamSelect.addEventListener("change", () => {
|
|
logsState.stream = el.logsStreamSelect.value;
|
|
refreshLogs("stream");
|
|
});
|
|
|
|
el.logsList.addEventListener("click", async (event) => {
|
|
const button = event.target.closest("button[data-log-action]");
|
|
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
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");
|
|
}
|
|
});
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
window.clearTimeout(state.timer);
|
|
window.clearTimeout(logsState.timer);
|
|
|
|
if (document.hidden) {
|
|
renderStatus();
|
|
renderLogsStatus();
|
|
return;
|
|
}
|
|
|
|
if (appState.panel === "logs" && logsState.auto) {
|
|
refreshLogs("visibility");
|
|
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;
|
|
}
|
|
|
|
refreshMessages("keyboard");
|
|
}
|
|
});
|
|
|
|
updatePaneTopButtonVisibility(el.emailsContentPane, el.scrollToTopButton);
|
|
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
|
|
}
|
|
|
|
async function setPanel(panel) {
|
|
if (!panel || panel === appState.panel) {
|
|
if (panel === "logs") {
|
|
await ensureLogsReady();
|
|
}
|
|
|
|
renderWorkspace();
|
|
return;
|
|
}
|
|
|
|
appState.panel = panel;
|
|
renderWorkspace();
|
|
|
|
if (panel === "logs") {
|
|
await ensureLogsReady();
|
|
}
|
|
|
|
scheduleRefresh();
|
|
scheduleLogsRefresh();
|
|
renderStatus();
|
|
renderLogsStatus();
|
|
}
|
|
|
|
function renderWorkspace() {
|
|
el.emailsPanel.hidden = appState.panel !== "emails";
|
|
el.logsPanel.hidden = appState.panel !== "logs";
|
|
|
|
el.workspaceTabs.querySelectorAll("button[data-panel]").forEach((button) => {
|
|
const active = button.dataset.panel === appState.panel;
|
|
button.classList.toggle("active", active);
|
|
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
async function ensureLogsReady() {
|
|
if (appState.logsReady) {
|
|
return;
|
|
}
|
|
|
|
await refreshLogGroups();
|
|
appState.logsReady = !logsState.error;
|
|
|
|
if (logsState.group) {
|
|
await refreshLogs("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 {
|
|
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 {
|
|
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 });
|
|
}
|
|
}
|
|
|
|
function applyLogsFilter(shouldRenderList = true) {
|
|
const search = logsState.search.trim().toLowerCase();
|
|
logsState.filtered = !search
|
|
? [...logsState.events]
|
|
: logsState.events.filter((event) => logHaystack(event).includes(search));
|
|
renderLogsAll({ 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 applyFilter(shouldRenderList = true) {
|
|
const search = state.search.trim().toLowerCase();
|
|
state.filtered = !search
|
|
? [...state.messages]
|
|
: state.messages.filter((message) => haystack(message).includes(search));
|
|
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 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 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 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 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;
|
|
}
|
|
|
|
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 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 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 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 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();
|
|
el.list.querySelectorAll(".card[open]").forEach((details) => hydrate(details, getMessage(details.dataset.id)));
|
|
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, index) => renderLogEvent(event, index)).join("");
|
|
bindLogToggles();
|
|
updatePaneTopButtonVisibility(el.logsContentPane, el.logsScrollToTopButton);
|
|
}
|
|
|
|
function renderLogEvent(event, index) {
|
|
const open = logsState.openIds.has(event.id) || (!logsState.hasInteracted && index === 0) ? "open" : "";
|
|
|
|
return `
|
|
<details class="logEvent" data-id="${escapeHtml(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>
|
|
</div>
|
|
<span class="tag">${escapeHtml(formatRelative(event.timestamp || Date.now()))}</span>
|
|
</div>
|
|
<p class="logPreview">${escapeHtml(event.preview || "No preview available.")}</p>
|
|
</summary>
|
|
<div class="logBody">
|
|
<div class="logCodeWrap">
|
|
<button class="mini logCopyButton" type="button" data-log-action="copy" data-id="${escapeHtml(event.id)}">Copy JSON</button>
|
|
<pre>${escapeHtml(formatLogMessage(event.message))}</pre>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
function bindLogToggles() {
|
|
el.logsList.querySelectorAll(".logEvent").forEach((details) => {
|
|
details.addEventListener("toggle", () => {
|
|
const id = details.dataset.id;
|
|
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
logsState.hasInteracted = true;
|
|
|
|
if (details.open) {
|
|
logsState.openIds.add(id);
|
|
} else {
|
|
logsState.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 open = state.openIds.has(message.id);
|
|
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)}" ${open ? "open" : ""}>
|
|
<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)}` : "";
|
|
return `<a class="attachment attachmentLink" href="/api/messages/${encodeURIComponent(message.id)}/attachments/${attachment.index}" download="${escapeHtml(attachment.filename)}" title="Download ${escapeHtml(attachment.filename)}">${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 loading="lazy"></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 metaCard(label, value) {
|
|
return `<div class="metaCard"><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 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 getInitialTheme() {
|
|
try {
|
|
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
|
|
if (storedTheme === "dark" || storedTheme === "light") {
|
|
return storedTheme;
|
|
}
|
|
} catch {}
|
|
|
|
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 ? "Theme: Dark" : "Theme: Light";
|
|
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 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 escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
async function safeJson(response) {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`LocalStack inspector is running on http://localhost:${PORT}`);
|
|
console.log(`Watching LocalStack SES endpoint at ${SES_ENDPOINT}`);
|
|
console.log(`Watching LocalStack CloudWatch Logs endpoint at ${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`);
|
|
});
|