feature/IO-3587-Commision-Cut-Clean - Split localStack client into smaller files / seperated by concerns
This commit is contained in:
@@ -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
@@ -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": "",
|
||||||
|
|||||||
3154
_reference/localEmailViewer/public/client-app.js
Normal file
3154
_reference/localEmailViewer/public/client-app.js
Normal file
File diff suppressed because it is too large
Load Diff
45
_reference/localEmailViewer/server/config.js
Normal file
45
_reference/localEmailViewer/server/config.js
Normal 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
|
||||||
|
});
|
||||||
845
_reference/localEmailViewer/server/localstack-service.js
Normal file
845
_reference/localEmailViewer/server/localstack-service.js
Normal 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
|
||||||
|
};
|
||||||
495
_reference/localEmailViewer/server/page.js
Normal file
495
_reference/localEmailViewer/server/page.js
Normal 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">↑</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">↑</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">↑</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">↑</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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getClientConfig, renderHtml };
|
||||||
Reference in New Issue
Block a user