feature/IO-3587-Commision-Cut-Clean - Split localStack client into smaller files / seperated by concerns

This commit is contained in:
Dave
2026-03-19 18:41:43 -04:00
parent 285043a6ba
commit 9d9e626cfe
7 changed files with 4592 additions and 4482 deletions

View File

@@ -32,6 +32,14 @@ Features:
- Shared LocalStack service health strip plus a reset action for clearing saved viewer state - Shared LocalStack service health strip plus a reset action for clearing saved viewer state
- Compact single-page UI for switching between the local stack tools you use most - Compact single-page UI for switching between the local stack tools you use most
Code layout:
- `index.js`: small Express bootstrap and route registration
- `server/config.js`: LocalStack endpoints, defaults, and AWS client setup
- `server/localstack-service.js`: SES, Logs, Secrets, and S3 data loading helpers
- `server/page.js`: server-rendered HTML shell, CSS, and client config payload
- `public/client-app.js`: browser-side UI state, rendering, refresh logic, and interactions
Optional environment variables: Optional environment variables:
```shell ```shell

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"check": "node --check index.js" "check": "node --check index.js && node --check public/client-app.js && node --check server/config.js && node --check server/localstack-service.js && node --check server/page.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { S3Client } from "@aws-sdk/client-s3";
export const PORT = Number(process.env.PORT || 3334);
export const SES_ENDPOINT = process.env.SES_VIEWER_ENDPOINT || "http://localhost:4566/_aws/ses";
export const FETCH_TIMEOUT_MS = Number(process.env.SES_VIEWER_FETCH_TIMEOUT_MS || 5000);
export const DEFAULT_REFRESH_MS = Number(process.env.SES_VIEWER_REFRESH_MS || 10000);
export const CLOUDWATCH_ENDPOINT = process.env.CLOUDWATCH_VIEWER_ENDPOINT || "http://localhost:4566";
export const CLOUDWATCH_REGION =
process.env.CLOUDWATCH_VIEWER_REGION || process.env.AWS_DEFAULT_REGION || "ca-central-1";
export const CLOUDWATCH_DEFAULT_GROUP = process.env.CLOUDWATCH_VIEWER_LOG_GROUP || "development";
export const CLOUDWATCH_DEFAULT_WINDOW_MS = Number(process.env.CLOUDWATCH_VIEWER_WINDOW_MS || 15 * 60 * 1000);
export const CLOUDWATCH_DEFAULT_LIMIT = Number(process.env.CLOUDWATCH_VIEWER_LIMIT || 200);
export const SECRETS_ENDPOINT = process.env.SECRETS_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
export const SECRETS_REGION = process.env.SECRETS_VIEWER_REGION || CLOUDWATCH_REGION;
export const S3_ENDPOINT = process.env.S3_VIEWER_ENDPOINT || CLOUDWATCH_ENDPOINT;
export const S3_REGION = process.env.S3_VIEWER_REGION || CLOUDWATCH_REGION;
export const S3_DEFAULT_BUCKET = process.env.S3_VIEWER_BUCKET || "";
export const S3_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_PREVIEW_BYTES || 256 * 1024);
export const S3_IMAGE_PREVIEW_MAX_BYTES = Number(process.env.S3_VIEWER_IMAGE_PREVIEW_BYTES || 1024 * 1024);
export const LOCALSTACK_CREDENTIALS = {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "test",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "test"
};
export const cloudWatchLogsClient = new CloudWatchLogsClient({
region: CLOUDWATCH_REGION,
endpoint: CLOUDWATCH_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS
});
export const secretsManagerClient = new SecretsManagerClient({
region: SECRETS_REGION,
endpoint: SECRETS_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS
});
export const s3Client = new S3Client({
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: LOCALSTACK_CREDENTIALS,
forcePathStyle: true
});

View File

@@ -0,0 +1,845 @@
import fetch from "node-fetch";
import {
DescribeLogGroupsCommand,
DescribeLogStreamsCommand,
FilterLogEventsCommand
} from "@aws-sdk/client-cloudwatch-logs";
import { GetSecretValueCommand, ListSecretsCommand } from "@aws-sdk/client-secrets-manager";
import { GetObjectCommand, HeadObjectCommand, ListBucketsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { simpleParser } from "mailparser";
import {
CLOUDWATCH_ENDPOINT,
CLOUDWATCH_REGION,
FETCH_TIMEOUT_MS,
S3_ENDPOINT,
S3_IMAGE_PREVIEW_MAX_BYTES,
S3_PREVIEW_MAX_BYTES,
S3_REGION,
SES_ENDPOINT,
SECRETS_ENDPOINT,
SECRETS_REGION,
cloudWatchLogsClient,
s3Client,
secretsManagerClient
} from "./config.js";
async function loadMessages() {
const startedAt = Date.now();
const sesMessages = await fetchSesMessages();
const messages = await Promise.all(sesMessages.map((message, index) => toMessageViewModel(message, index)));
messages.sort((left, right) => {
if ((right.timestampMs || 0) !== (left.timestampMs || 0)) {
return (right.timestampMs || 0) - (left.timestampMs || 0);
}
return right.index - left.index;
});
return {
endpoint: SES_ENDPOINT,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalMessages: messages.length,
parseErrors: messages.filter((message) => Boolean(message.parseError)).length,
latestMessageTimestamp: messages[0]?.timestamp || "",
messages
};
}
async function fetchSesMessages() {
const response = await fetch(SES_ENDPOINT, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!response.ok) {
throw new Error(`SES endpoint responded with ${response.status}`);
}
const data = await response.json();
return Array.isArray(data.messages) ? data.messages : [];
}
async function loadLogGroups() {
const groups = [];
let nextToken;
let pageCount = 0;
do {
const response = await cloudWatchLogsClient.send(
new DescribeLogGroupsCommand({
nextToken,
limit: 50
})
);
groups.push(
...(response.logGroups || []).map((group) => ({
name: group.logGroupName || "",
arn: group.arn || "",
storedBytes: group.storedBytes || 0,
retentionInDays: group.retentionInDays || 0,
creationTime: group.creationTime || 0
}))
);
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && pageCount < 10);
return groups.sort((left, right) => left.name.localeCompare(right.name));
}
async function loadLogStreams(logGroupName) {
const streams = [];
let nextToken;
let pageCount = 0;
do {
const response = await cloudWatchLogsClient.send(
new DescribeLogStreamsCommand({
logGroupName,
descending: true,
orderBy: "LastEventTime",
nextToken,
limit: 50
})
);
streams.push(
...(response.logStreams || []).map((stream) => ({
name: stream.logStreamName || "",
arn: stream.arn || "",
lastEventTimestamp: stream.lastEventTimestamp || 0,
lastIngestionTime: stream.lastIngestionTime || 0,
storedBytes: stream.storedBytes || 0
}))
);
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && pageCount < 6 && streams.length < 250);
return streams;
}
async function loadLogEvents({ logGroupName, logStreamName, windowMs, limit }) {
const startedAt = Date.now();
const eventMap = new Map();
const startTime = Date.now() - windowMs;
let nextToken;
let previousToken = "";
let pageCount = 0;
let searchedLogStreams = 0;
do {
const response = await cloudWatchLogsClient.send(
new FilterLogEventsCommand({
logGroupName,
logStreamNames: logStreamName ? [logStreamName] : undefined,
startTime,
endTime: Date.now(),
limit,
nextToken
})
);
for (const event of response.events || []) {
const id =
event.eventId || `${event.logStreamName || "stream"}-${event.timestamp || 0}-${event.ingestionTime || 0}`;
if (!eventMap.has(id)) {
const message = String(event.message || "").trim();
eventMap.set(id, {
id,
timestamp: event.timestamp || 0,
ingestionTime: event.ingestionTime || 0,
logStreamName: event.logStreamName || "",
message,
preview: buildLogPreview(message)
});
}
}
searchedLogStreams = Math.max(searchedLogStreams, (response.searchedLogStreams || []).length);
previousToken = nextToken || "";
nextToken = response.nextToken;
pageCount += 1;
} while (nextToken && nextToken !== previousToken && pageCount < 10 && eventMap.size < limit);
const events = [...eventMap.values()]
.sort((left, right) => {
if ((right.timestamp || 0) !== (left.timestamp || 0)) {
return (right.timestamp || 0) - (left.timestamp || 0);
}
return left.logStreamName.localeCompare(right.logStreamName);
})
.slice(0, limit);
return {
endpoint: CLOUDWATCH_ENDPOINT,
region: CLOUDWATCH_REGION,
logGroupName,
logStreamName,
fetchDurationMs: Date.now() - startedAt,
latestTimestamp: events[0]?.timestamp || 0,
searchedLogStreams,
totalEvents: events.length,
events
};
}
async function loadSecrets() {
const startedAt = Date.now();
const secrets = [];
let nextToken;
let pageCount = 0;
do {
const response = await secretsManagerClient.send(
new ListSecretsCommand({
NextToken: nextToken,
MaxResults: 50
})
);
secrets.push(
...(response.SecretList || []).map((secret, index) => ({
id: secret.ARN || secret.Name || `secret-${index}`,
name: secret.Name || "Unnamed secret",
arn: secret.ARN || "",
description: secret.Description || "",
createdDate: normalizeTimestamp(secret.CreatedDate),
lastChangedDate: normalizeTimestamp(secret.LastChangedDate),
lastAccessedDate: normalizeTimestamp(secret.LastAccessedDate),
deletedDate: normalizeTimestamp(secret.DeletedDate),
primaryRegion: secret.PrimaryRegion || "",
owningService: secret.OwningService || "",
rotationEnabled: Boolean(secret.RotationEnabled),
versionCount: Object.keys(secret.SecretVersionsToStages || {}).length,
tagCount: Array.isArray(secret.Tags) ? secret.Tags.length : 0,
tags: (secret.Tags || [])
.map((tag) => ({
key: tag.Key || "",
value: tag.Value || ""
}))
.filter((tag) => tag.key || tag.value)
}))
);
nextToken = response.NextToken;
pageCount += 1;
} while (nextToken && pageCount < 10 && secrets.length < 500);
secrets.sort((left, right) => {
const leftTime = Date.parse(left.lastChangedDate || left.createdDate || 0) || 0;
const rightTime = Date.parse(right.lastChangedDate || right.createdDate || 0) || 0;
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.name.localeCompare(right.name);
});
return {
endpoint: SECRETS_ENDPOINT,
region: SECRETS_REGION,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalSecrets: secrets.length,
latestTimestamp: secrets[0]?.lastChangedDate || secrets[0]?.createdDate || "",
secrets
};
}
async function loadSecretValue(secretId) {
const startedAt = Date.now();
const response = await secretsManagerClient.send(
new GetSecretValueCommand({
SecretId: secretId
})
);
const secretBinary = response.SecretBinary
? typeof response.SecretBinary === "string"
? response.SecretBinary
: Buffer.from(response.SecretBinary).toString("base64")
: "";
return {
endpoint: SECRETS_ENDPOINT,
region: SECRETS_REGION,
fetchDurationMs: Date.now() - startedAt,
id: secretId,
name: response.Name || "",
arn: response.ARN || "",
versionId: response.VersionId || "",
versionStages: Array.isArray(response.VersionStages) ? response.VersionStages : [],
createdDate: normalizeTimestamp(response.CreatedDate),
secretString: typeof response.SecretString === "string" ? response.SecretString : "",
secretBinary
};
}
async function loadS3Buckets() {
const startedAt = Date.now();
const response = await s3Client.send(new ListBucketsCommand({}));
const buckets = (response.Buckets || [])
.map((bucket) => ({
name: bucket.Name || "",
creationDate: normalizeTimestamp(bucket.CreationDate)
}))
.filter((bucket) => bucket.name)
.sort((left, right) => left.name.localeCompare(right.name));
return {
endpoint: S3_ENDPOINT,
region: S3_REGION,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalBuckets: buckets.length,
buckets
};
}
async function loadS3Objects({ bucket, prefix }) {
const startedAt = Date.now();
const objects = [];
let continuationToken;
let pageCount = 0;
do {
const response = await s3Client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix || undefined,
ContinuationToken: continuationToken,
MaxKeys: 200
})
);
objects.push(
...(response.Contents || []).map((object, index) => ({
id: `${bucket}::${object.Key || index}`,
bucket,
key: object.Key || "",
size: object.Size || 0,
lastModified: normalizeTimestamp(object.LastModified),
etag: String(object.ETag || "").replace(/^"|"$/g, ""),
storageClass: object.StorageClass || "STANDARD"
}))
);
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
pageCount += 1;
} while (continuationToken && pageCount < 10 && objects.length < 1000);
objects.sort((left, right) => {
const leftTime = Date.parse(left.lastModified || 0) || 0;
const rightTime = Date.parse(right.lastModified || 0) || 0;
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.key.localeCompare(right.key);
});
return {
endpoint: S3_ENDPOINT,
region: S3_REGION,
bucket,
prefix,
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
totalObjects: objects.length,
latestTimestamp: objects[0]?.lastModified || "",
objects
};
}
async function loadS3ObjectPreview({ bucket, key }) {
const startedAt = Date.now();
const head = await s3Client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key
})
);
const contentType = head.ContentType || guessObjectContentType(key);
const contentLength = Number(head.ContentLength || 0);
const previewType = resolveS3PreviewType(contentType, key);
const result = {
endpoint: S3_ENDPOINT,
region: S3_REGION,
bucket,
key,
fetchDurationMs: 0,
contentType,
contentLength,
etag: String(head.ETag || "").replace(/^"|"$/g, ""),
lastModified: normalizeTimestamp(head.LastModified),
metadata: head.Metadata || {},
previewType,
previewText: "",
imageDataUrl: "",
truncated: false
};
const shouldLoadTextPreview = previewType === "json" || previewType === "text" || previewType === "html";
const shouldLoadImagePreview =
previewType === "image" && contentLength > 0 && contentLength <= S3_IMAGE_PREVIEW_MAX_BYTES;
if ((shouldLoadTextPreview || shouldLoadImagePreview) && contentLength > 0) {
const previewBytes = Math.max(1, Math.min(contentLength || S3_PREVIEW_MAX_BYTES, S3_PREVIEW_MAX_BYTES));
const response = await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
Range: `bytes=0-${previewBytes - 1}`
})
);
const content = Buffer.from(await response.Body.transformToByteArray());
result.truncated = contentLength > content.length;
if (shouldLoadImagePreview) {
result.imageDataUrl = `data:${contentType};base64,${content.toString("base64")}`;
} else {
result.previewText = content.toString("utf8");
}
}
result.fetchDurationMs = Date.now() - startedAt;
return result;
}
async function loadServiceHealthSummary() {
const startedAt = Date.now();
const [sesResult, logsResult, secretsResult, s3Result] = await Promise.allSettled([
fetchSesMessages(),
loadLogGroups(),
loadSecrets(),
loadS3Buckets()
]);
return {
fetchedAt: new Date().toISOString(),
fetchDurationMs: Date.now() - startedAt,
services: {
emails: summarizeHealthResult({
icon: "✉️",
panel: "emails",
label: "SES Emails",
result: sesResult,
count: sesResult.status === "fulfilled" ? sesResult.value.length : 0,
detail: SES_ENDPOINT,
noun: "email"
}),
logs: summarizeHealthResult({
icon: "📜",
panel: "logs",
label: "CloudWatch Logs",
result: logsResult,
count: logsResult.status === "fulfilled" ? logsResult.value.length : 0,
detail: `${CLOUDWATCH_ENDPOINT} (${CLOUDWATCH_REGION})`,
noun: "group"
}),
secrets: summarizeHealthResult({
icon: "🔐",
panel: "secrets",
label: "Secrets Manager",
result: secretsResult,
count: secretsResult.status === "fulfilled" ? secretsResult.value.totalSecrets : 0,
detail: `${SECRETS_ENDPOINT} (${SECRETS_REGION})`,
noun: "secret"
}),
s3: summarizeHealthResult({
icon: "🪣",
panel: "s3",
label: "S3 Explorer",
result: s3Result,
count: s3Result.status === "fulfilled" ? s3Result.value.totalBuckets : 0,
detail: `${S3_ENDPOINT} (${S3_REGION})`,
noun: "bucket"
})
}
};
}
async function findSesMessageById(id) {
const messages = await fetchSesMessages();
return messages.find((message, index) => resolveMessageId(message, index) === id) || null;
}
async function parseSesMessageById(id) {
const message = await findSesMessageById(id);
if (!message) {
return null;
}
return simpleParser(message.RawData || "");
}
async function toMessageViewModel(message, index) {
const id = resolveMessageId(message, index);
try {
const parsed = await simpleParser(message.RawData || "");
const textContent = normalizeText(parsed.text || "");
const renderedHtml = buildRenderedHtml(parsed.html || parsed.textAsHtml || "");
const timestamp = normalizeTimestamp(message.Timestamp || parsed.date);
return {
id,
index,
from: formatAddressList(parsed.from) || message.Source || "Unknown sender",
to: formatAddressList(parsed.to) || "No To Address",
replyTo: formatAddressList(parsed.replyTo),
subject: parsed.subject || "No Subject",
region: message.Region || "",
timestamp,
timestampMs: timestamp ? Date.parse(timestamp) : 0,
messageId: parsed.messageId || "",
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
attachmentCount: parsed.attachments.length,
attachments: parsed.attachments.map((attachment, attachmentIndex) => ({
index: attachmentIndex,
filename: resolveAttachmentFilename(attachment, attachmentIndex),
contentType: attachment.contentType || "application/octet-stream",
size: attachment.size || 0
})),
preview: buildPreview(textContent, renderedHtml),
textContent,
renderedHtml,
hasHtml: Boolean(renderedHtml),
parseError: ""
};
} catch (error) {
return {
id,
index,
from: message.Source || "Unknown sender",
to: "Unknown recipient",
replyTo: "",
subject: "Unable to parse message",
region: message.Region || "",
timestamp: normalizeTimestamp(message.Timestamp),
timestampMs: message.Timestamp ? Date.parse(message.Timestamp) : 0,
messageId: "",
rawSizeBytes: Buffer.byteLength(message.RawData || "", "utf8"),
attachmentCount: 0,
attachments: [],
preview: "This message could not be parsed. Open the raw view to inspect the MIME source.",
textContent: "",
renderedHtml: "",
hasHtml: false,
parseError: error.message
};
}
}
function resolveMessageId(message, index = 0) {
return message.Id || `${message.Timestamp || "unknown"}-${message.Source || "unknown"}-${index}`;
}
function resolveAttachmentFilename(attachment, index = 0) {
if (attachment?.filename) {
return attachment.filename;
}
return `attachment-${index + 1}${attachmentExtension(attachment?.contentType)}`;
}
function attachmentExtension(contentType) {
const normalized = String(contentType || "")
.split(";")[0]
.trim()
.toLowerCase();
return (
{
"application/json": ".json",
"application/pdf": ".pdf",
"application/zip": ".zip",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"text/calendar": ".ics",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt"
}[normalized] || ""
);
}
function buildAttachmentDisposition(filename) {
const fallback = String(filename || "attachment")
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "_");
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "attachment")}`;
}
function buildInlineDisposition(filename) {
const fallback = String(filename || "file")
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "_");
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename || "file")}`;
}
function basenameFromKey(key) {
const value = String(key || "");
const parts = value.split("/").filter(Boolean);
return parts[parts.length - 1] || "file";
}
function guessObjectContentType(key) {
const normalizedKey = String(key || "").toLowerCase();
if (normalizedKey.endsWith(".json")) {
return "application/json";
}
if (normalizedKey.endsWith(".csv")) {
return "text/csv";
}
if (normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
return "text/html";
}
if (normalizedKey.endsWith(".txt") || normalizedKey.endsWith(".log") || normalizedKey.endsWith(".md")) {
return "text/plain";
}
if (normalizedKey.endsWith(".png")) {
return "image/png";
}
if (normalizedKey.endsWith(".jpg") || normalizedKey.endsWith(".jpeg")) {
return "image/jpeg";
}
if (normalizedKey.endsWith(".gif")) {
return "image/gif";
}
if (normalizedKey.endsWith(".webp")) {
return "image/webp";
}
if (normalizedKey.endsWith(".svg")) {
return "image/svg+xml";
}
if (normalizedKey.endsWith(".pdf")) {
return "application/pdf";
}
return "application/octet-stream";
}
function resolveS3PreviewType(contentType, key) {
const normalizedType = String(contentType || "").toLowerCase();
const normalizedKey = String(key || "").toLowerCase();
if (normalizedType.includes("json") || normalizedKey.endsWith(".json")) {
return "json";
}
if (normalizedType.startsWith("image/")) {
return "image";
}
if (normalizedType.includes("html") || normalizedKey.endsWith(".html") || normalizedKey.endsWith(".htm")) {
return "html";
}
if (
normalizedType.startsWith("text/") ||
[".txt", ".log", ".csv", ".xml", ".yml", ".yaml", ".md"].some((extension) => normalizedKey.endsWith(extension))
) {
return "text";
}
return "binary";
}
function summarizeHealthResult({ icon, panel, label, result, count, detail, noun }) {
if (result.status === "fulfilled") {
return {
ok: true,
icon,
panel,
label,
count,
summary: `${count} ${noun}${count === 1 ? "" : "s"}`,
detail
};
}
return {
ok: false,
icon,
panel,
label,
count: 0,
summary: "Needs attention",
detail: result.reason?.message || detail
};
}
function normalizeTimestamp(value) {
if (!value) {
return "";
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
}
function normalizeText(value) {
return String(value || "")
.replace(/\r\n/g, "\n")
.trim();
}
function buildPreview(textContent, renderedHtml) {
const source = (textContent || stripTags(renderedHtml)).replace(/\s+/g, " ").trim();
if (!source) {
return "No message preview available.";
}
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
}
function buildLogPreview(message) {
const source = String(message || "")
.replace(/\s+/g, " ")
.trim();
if (!source) {
return "No log preview available.";
}
return source.length > 220 ? `${source.slice(0, 217)}...` : source;
}
function clampNumber(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.min(Math.max(parsed, min), max);
}
function buildRenderedHtml(html) {
if (!html) {
return "";
}
const value = String(html);
const hasDocument = /<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(", ");
}
async function loadMessageAttachment(messageId, attachmentIndex) {
const parsed = await parseSesMessageById(messageId);
if (!parsed) {
return null;
}
const attachment = parsed.attachments?.[attachmentIndex];
if (!attachment) {
return null;
}
return {
filename: resolveAttachmentFilename(attachment, attachmentIndex),
contentType: attachment.contentType || "application/octet-stream",
content: Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content || "")
};
}
async function loadS3ObjectDownload({ bucket, key }) {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key
})
);
return {
filename: basenameFromKey(key),
contentType: response.ContentType || guessObjectContentType(key),
content: Buffer.from(await response.Body.transformToByteArray())
};
}
export {
buildAttachmentDisposition,
buildInlineDisposition,
clampNumber,
findSesMessageById,
loadLogEvents,
loadLogGroups,
loadLogStreams,
loadMessageAttachment,
loadMessages,
loadS3Buckets,
loadS3ObjectDownload,
loadS3ObjectPreview,
loadS3Objects,
loadSecretValue,
loadSecrets,
loadServiceHealthSummary
};

View File

@@ -0,0 +1,495 @@
import {
CLOUDWATCH_DEFAULT_GROUP,
CLOUDWATCH_DEFAULT_LIMIT,
CLOUDWATCH_DEFAULT_WINDOW_MS,
CLOUDWATCH_ENDPOINT,
CLOUDWATCH_REGION,
DEFAULT_REFRESH_MS,
S3_DEFAULT_BUCKET,
S3_ENDPOINT,
S3_REGION,
SECRETS_ENDPOINT,
SECRETS_REGION,
SES_ENDPOINT
} from "./config.js";
function getClientConfig() {
return {
defaultRefreshMs: DEFAULT_REFRESH_MS,
endpoint: SES_ENDPOINT,
cloudWatchEndpoint: CLOUDWATCH_ENDPOINT,
cloudWatchRegion: CLOUDWATCH_REGION,
secretsEndpoint: SECRETS_ENDPOINT,
secretsRegion: SECRETS_REGION,
s3Endpoint: S3_ENDPOINT,
s3Region: S3_REGION,
defaultS3Bucket: S3_DEFAULT_BUCKET,
defaultLogGroup: CLOUDWATCH_DEFAULT_GROUP,
defaultLogWindowMs: CLOUDWATCH_DEFAULT_WINDOW_MS,
defaultLogLimit: CLOUDWATCH_DEFAULT_LIMIT
};
}
function renderHtml() {
return `<!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;");
}
export { getClientConfig, renderHtml };