Add resource inspector to Quantum dashboard
This commit is contained in:
@@ -302,6 +302,30 @@ Expected evidence:
|
|||||||
Hello Quantum
|
Hello Quantum
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Quantum screen includes action buttons and inspection buttons:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Refresh Evidence
|
||||||
|
Write S3 Evidence
|
||||||
|
Send SQS Message
|
||||||
|
Check Lambda
|
||||||
|
Inspect S3
|
||||||
|
Inspect SQS
|
||||||
|
Inspect Lambda
|
||||||
|
Inspect IAM
|
||||||
|
Inspect Secret
|
||||||
|
Inspect Logs
|
||||||
|
```
|
||||||
|
|
||||||
|
The inspection buttons call the `quanto-api` backend and return live LocalStack data:
|
||||||
|
|
||||||
|
- `Inspect S3` lists objects in the Quantum bucket.
|
||||||
|
- `Inspect SQS` shows queue counters, DLQ counters, and a sample of visible messages.
|
||||||
|
- `Inspect Lambda` shows runtime, handler, state, environment variables, and event source mappings.
|
||||||
|
- `Inspect IAM` shows the Lambda role and attached policies.
|
||||||
|
- `Inspect Secret` shows the application secret metadata and current value.
|
||||||
|
- `Inspect Logs` shows the CloudWatch Log Group and recent log streams.
|
||||||
|
|
||||||
On the Docker Swarm host:
|
On the Docker Swarm host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -158,6 +158,150 @@ def send_sqs_message():
|
|||||||
return jsonify(ok("sqs-message", {"queue": QUEUE_NAME, "messageId": response.get("MessageId")}))
|
return jsonify(ok("sqs-message", {"queue": QUEUE_NAME, "messageId": response.get("MessageId")}))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/s3/objects")
|
||||||
|
def list_s3_objects():
|
||||||
|
response = client("s3").list_objects_v2(Bucket=BUCKET_NAME)
|
||||||
|
objects = [
|
||||||
|
{
|
||||||
|
"key": item.get("Key"),
|
||||||
|
"size": item.get("Size"),
|
||||||
|
"lastModified": item.get("LastModified").isoformat() if item.get("LastModified") else None,
|
||||||
|
"etag": item.get("ETag"),
|
||||||
|
}
|
||||||
|
for item in response.get("Contents", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify(ok("s3-objects", {"bucket": BUCKET_NAME, "objectCount": len(objects), "objects": objects}))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sqs/details")
|
||||||
|
def sqs_details():
|
||||||
|
sqs = client("sqs")
|
||||||
|
main_url = queue_url(QUEUE_NAME)
|
||||||
|
dlq_url = queue_url(DLQ_NAME)
|
||||||
|
attributes = [
|
||||||
|
"ApproximateNumberOfMessages",
|
||||||
|
"ApproximateNumberOfMessagesNotVisible",
|
||||||
|
"ApproximateNumberOfMessagesDelayed",
|
||||||
|
"CreatedTimestamp",
|
||||||
|
"LastModifiedTimestamp",
|
||||||
|
"VisibilityTimeout",
|
||||||
|
]
|
||||||
|
|
||||||
|
main_attrs = sqs.get_queue_attributes(QueueUrl=main_url, AttributeNames=attributes)["Attributes"]
|
||||||
|
dlq_attrs = sqs.get_queue_attributes(QueueUrl=dlq_url, AttributeNames=attributes)["Attributes"]
|
||||||
|
messages = sqs.receive_message(
|
||||||
|
QueueUrl=main_url,
|
||||||
|
MaxNumberOfMessages=5,
|
||||||
|
VisibilityTimeout=0,
|
||||||
|
WaitTimeSeconds=0,
|
||||||
|
AttributeNames=["All"],
|
||||||
|
).get("Messages", [])
|
||||||
|
|
||||||
|
visible_messages = [
|
||||||
|
{
|
||||||
|
"messageId": message.get("MessageId"),
|
||||||
|
"body": message.get("Body"),
|
||||||
|
"attributes": message.get("Attributes", {}),
|
||||||
|
}
|
||||||
|
for message in messages
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
ok(
|
||||||
|
"sqs-details",
|
||||||
|
{
|
||||||
|
"queue": {"name": QUEUE_NAME, "url": main_url, "attributes": main_attrs},
|
||||||
|
"dlq": {"name": DLQ_NAME, "url": dlq_url, "attributes": dlq_attrs},
|
||||||
|
"visibleMessagesSample": visible_messages,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/lambda/details")
|
||||||
|
def lambda_details():
|
||||||
|
lambda_client = client("lambda")
|
||||||
|
function = lambda_client.get_function(FunctionName=LAMBDA_NAME)
|
||||||
|
configuration = function.get("Configuration", {})
|
||||||
|
mappings = lambda_client.list_event_source_mappings(FunctionName=LAMBDA_NAME).get("EventSourceMappings", [])
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
ok(
|
||||||
|
"lambda-details",
|
||||||
|
{
|
||||||
|
"functionName": configuration.get("FunctionName"),
|
||||||
|
"runtime": configuration.get("Runtime"),
|
||||||
|
"handler": configuration.get("Handler"),
|
||||||
|
"state": configuration.get("State"),
|
||||||
|
"lastModified": configuration.get("LastModified"),
|
||||||
|
"role": configuration.get("Role"),
|
||||||
|
"environment": configuration.get("Environment", {}).get("Variables", {}),
|
||||||
|
"eventSourceMappings": [
|
||||||
|
{
|
||||||
|
"uuid": mapping.get("UUID"),
|
||||||
|
"state": mapping.get("State"),
|
||||||
|
"batchSize": mapping.get("BatchSize"),
|
||||||
|
"eventSourceArn": mapping.get("EventSourceArn"),
|
||||||
|
}
|
||||||
|
for mapping in mappings
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/iam/details")
|
||||||
|
def iam_details():
|
||||||
|
iam = client("iam")
|
||||||
|
role = iam.get_role(RoleName=ROLE_NAME)["Role"]
|
||||||
|
policies = iam.list_attached_role_policies(RoleName=ROLE_NAME).get("AttachedPolicies", [])
|
||||||
|
|
||||||
|
return jsonify(ok("iam-details", {"role": role, "attachedPolicies": policies}))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/secrets/details")
|
||||||
|
def secrets_details():
|
||||||
|
secrets = client("secretsmanager")
|
||||||
|
description = secrets.describe_secret(SecretId=SECRET_NAME)
|
||||||
|
value = secrets.get_secret_value(SecretId=SECRET_NAME)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
ok(
|
||||||
|
"secrets-details",
|
||||||
|
{
|
||||||
|
"name": description.get("Name"),
|
||||||
|
"arn": description.get("ARN"),
|
||||||
|
"versionIds": list(description.get("VersionIdsToStages", {}).keys()),
|
||||||
|
"secretString": value.get("SecretString"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/logs/details")
|
||||||
|
def log_details():
|
||||||
|
logs_client = client("logs")
|
||||||
|
groups = logs_client.describe_log_groups(logGroupNamePrefix=LOG_GROUP_NAME).get("logGroups", [])
|
||||||
|
streams = logs_client.describe_log_streams(
|
||||||
|
logGroupName=LOG_GROUP_NAME,
|
||||||
|
orderBy="LastEventTime",
|
||||||
|
descending=True,
|
||||||
|
limit=5,
|
||||||
|
).get("logStreams", []) if groups else []
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
ok(
|
||||||
|
"cloudwatch-logs-details",
|
||||||
|
{
|
||||||
|
"logGroup": LOG_GROUP_NAME,
|
||||||
|
"groups": groups,
|
||||||
|
"streams": streams,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/lambda/invoke")
|
@app.post("/api/lambda/invoke")
|
||||||
def invoke_lambda():
|
def invoke_lambda():
|
||||||
response = client("lambda").invoke(
|
response = client("lambda").invoke(
|
||||||
|
|||||||
@@ -129,6 +129,10 @@
|
|||||||
.result {
|
.result {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inspect {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -146,16 +150,30 @@
|
|||||||
<button id="lambda" class="secondary" type="button">Check Lambda</button>
|
<button id="lambda" class="secondary" type="button">Check Lambda</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Inspect resources">
|
||||||
|
<button data-inspect="/api/s3/objects" class="secondary" type="button">Inspect S3</button>
|
||||||
|
<button data-inspect="/api/sqs/details" class="secondary" type="button">Inspect SQS</button>
|
||||||
|
<button data-inspect="/api/lambda/details" class="secondary" type="button">Inspect Lambda</button>
|
||||||
|
<button data-inspect="/api/iam/details" class="secondary" type="button">Inspect IAM</button>
|
||||||
|
<button data-inspect="/api/secrets/details" class="secondary" type="button">Inspect Secret</button>
|
||||||
|
<button data-inspect="/api/logs/details" class="secondary" type="button">Inspect Logs</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="cards" class="grid" aria-live="polite"></section>
|
<section id="cards" class="grid" aria-live="polite"></section>
|
||||||
<section class="card result">
|
<section class="card result">
|
||||||
<h2>Last Action</h2>
|
<h2>Last Action</h2>
|
||||||
<pre id="result">Ready.</pre>
|
<pre id="result">Ready.</pre>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="card inspect">
|
||||||
|
<h2>Resource Inspector</h2>
|
||||||
|
<pre id="inspect">Select a resource to inspect.</pre>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const cards = document.querySelector("#cards");
|
const cards = document.querySelector("#cards");
|
||||||
const result = document.querySelector("#result");
|
const result = document.querySelector("#result");
|
||||||
|
const inspect = document.querySelector("#inspect");
|
||||||
|
|
||||||
function renderJson(value) {
|
function renderJson(value) {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
@@ -165,6 +183,10 @@
|
|||||||
result.textContent = typeof value === "string" ? value : renderJson(value);
|
result.textContent = typeof value === "string" ? value : renderJson(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setInspect(value) {
|
||||||
|
inspect.textContent = typeof value === "string" ? value : renderJson(value);
|
||||||
|
}
|
||||||
|
|
||||||
function renderChecks(data) {
|
function renderChecks(data) {
|
||||||
cards.innerHTML = "";
|
cards.innerHTML = "";
|
||||||
|
|
||||||
@@ -214,12 +236,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function inspectResource(path) {
|
||||||
|
try {
|
||||||
|
setInspect("Loading resource details...");
|
||||||
|
const data = await request(path);
|
||||||
|
setInspect(data);
|
||||||
|
} catch (error) {
|
||||||
|
setInspect({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector("#refresh").addEventListener("click", () => {
|
document.querySelector("#refresh").addEventListener("click", () => {
|
||||||
refresh().catch((error) => setResult({ error: error.message }));
|
refresh().catch((error) => setResult({ error: error.message }));
|
||||||
});
|
});
|
||||||
document.querySelector("#s3").addEventListener("click", () => runAction("/api/s3/marker"));
|
document.querySelector("#s3").addEventListener("click", () => runAction("/api/s3/marker"));
|
||||||
document.querySelector("#sqs").addEventListener("click", () => runAction("/api/sqs/message"));
|
document.querySelector("#sqs").addEventListener("click", () => runAction("/api/sqs/message"));
|
||||||
document.querySelector("#lambda").addEventListener("click", () => runAction("/api/lambda/invoke"));
|
document.querySelector("#lambda").addEventListener("click", () => runAction("/api/lambda/invoke"));
|
||||||
|
document.querySelectorAll("[data-inspect]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => inspectResource(button.dataset.inspect));
|
||||||
|
});
|
||||||
|
|
||||||
refresh().catch((error) => setResult({ error: error.message }));
|
refresh().catch((error) => setResult({ error: error.message }));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user