Files
floci-ui/packages/frontend/src/components/ResourceInspector.tsx
T
2026-10-05 11:06:57 -04:00

536 lines
22 KiB
TypeScript

import {useState} from 'react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails";
import { LogsExplorerPanel } from "@/components/LogsExplorerPanel";
import { LogsQueryPanel } from "@/components/LogsQueryPanel";
import type { CloudProvider } from "@/types/cloud";
import type { CloudResource, StorageObject } from "@/types/resource";
import {formatBytes} from "@/lib/format";
import {CopyValueButton} from "@/components/CopyValueButton";
import {rebootCloudResource, startCloudResource, stopCloudResource} from "@/api/cloudProxyClient";
import {cloudQueryKeys} from "@/api/queries/cloudQueries";
import {decodeRawMime, htmlMimePart, inlineMimeImages, parseMimeAttachments} from '@/lib/emailMime'
interface ResourceInspectorProps {
resource?: CloudResource;
object?: StorageObject;
cloud?: CloudProvider;
runtimeReachable?: boolean;
serviceName?: string;
}
export function ResourceInspector({
resource,
object,
cloud,
runtimeReachable,
serviceName,
}: ResourceInspectorProps) {
if (!resource) {
return (
<div className="resource-inspector empty compact">
<p className="eyebrow">Resource Inspector</p>
<h3>Select a resource</h3>
<p>Inspect normalized metadata returned by the cloud proxy.</p>
</div>
);
}
if (object) {
return (
<aside className="resource-inspector">
<div className="widget-header">
<h3>{object.name}</h3>
<span className="badge neutral">{object.type}</span>
</div>
<div className="inspector-grid">
<InspectorItem label="Cloud" value={resource.cloud} />
<InspectorItem label="Resource" value={resource.name} />
<InspectorItem label="Key" value={object.key} />
<InspectorItem
label="Size"
value={object.size === null ? "-" : formatBytes(object.size)}
/>
<InspectorItem
label="Last Modified"
value={object.lastModified ?? "-"}
/>
</div>
<MetadataPanel metadata={object.metadata} />
</aside>
);
}
if (resource.type === 'email') {
return <EmailInspector key={resource.id} resource={resource} />
}
const tags = getTags(resource.metadata.tags);
const tagsUnavailable = getBooleanMetadata(resource.metadata.tagsUnavailable) ?? false;
const versioning = getStringMetadata(resource.metadata.versioning);
const versioningEnabled = getBooleanMetadata(
resource.metadata.versioningEnabled,
);
const isDatabase =
resource.service === "database" || resource.type === "db-instance";
const isAwsDatabase = isDatabase && resource.cloud === "aws";
const isK8sEngine = resource.service === "k8s" || resource.type === "cluster";
const isLambda =
resource.service === "serverless" || resource.type === "lambda";
const isLogGroup = resource.cloud === "aws" && (resource.service === "logs" || resource.type === "log-group");
return (
<aside className="resource-inspector">
<div className="widget-header">
<h3>{resource.name}</h3>
<span className="badge neutral">{resource.type}</span>
</div>
<div className="inspector-grid">
<InspectorItem label="Cloud" value={resource.cloud} />
<InspectorItem label="Resource ID" value={resource.id} copyable />
<InspectorItem label="Service" value={serviceName ?? resource.service} />
<InspectorItem label="Region" value={resource.region ?? "-"} />
<InspectorItem label="Created At" value={resource.createdAt ?? "-"} />
{resource.status && (
<InspectorItem label="Status" value={resource.status} />
)}
{resource.engine && (
<InspectorItem label="Engine" value={resource.engine} />
)}
{resource.version && (
<InspectorItem label="Version" value={resource.version} />
)}
{resource.instanceClass && (
<InspectorItem label="Class" value={resource.instanceClass} />
)}
{versioning && <InspectorItem label="Versioning" value={versioning} />}
{versioningEnabled !== null && (
<InspectorItem
label="Versioning Enabled"
value={versioningEnabled ? "Yes" : "No"}
/>
)}
<InspectorItem
label="Tags"
value={tagsUnavailable ? "Unavailable" : `${tags.length}`}
/>
{isLambda && (
<>
<InspectorItem
label="Runtime"
value={getStringMetadata(resource.metadata.runtime) ?? "-"}
/>
<InspectorItem
label="Handler"
value={getStringMetadata(resource.metadata.handler) ?? "-"}
/>
<InspectorItem
label="Package Type"
value={getStringMetadata(resource.metadata.packageType) ?? "-"}
/>
</>
)}
</div>
<section className="inspector-section">
<p className="metric-label">Tags</p>
{tagsUnavailable ? (
<p className="muted compact-text">
Tags unavailable: the provider denied or failed the tag lookup.
</p>
) : tags.length === 0 ? (
<p className="muted compact-text">
No tags returned for this resource.
</p>
) : (
<div className="metadata-tags">
{tags.map((tag) => (
<span className="metadata-tag" key={`${tag.key}:${tag.value}`}>
<strong>{tag.key}</strong>
<span>{tag.value}</span>
</span>
))}
</div>
)}
</section>
{isDatabase && (
<DatabaseConnectionsSection metadata={resource.metadata} engine={resource.engine} status={resource.status} />
)}
{isAwsDatabase && <DatabaseLifecycleSection resource={resource} />}
{isDatabase && !isAwsDatabase && (
<ProviderDatabaseSection cloud={resource.cloud} />
)}
{isK8sEngine && (
<K8sEngineDetails cloud={resource.cloud} clusterName={resource.name} />
)}
{isLambda && (
<section className="inspector-section">
<p className="metric-label">Lambda Details</p>
<div className="inspector-grid compact-grid">
<InspectorItem
label="ARN"
value={getStringMetadata(resource.metadata.arn) ?? "-"}
copyable
/>
<InspectorItem
label="Last Modified"
value={getStringMetadata(resource.metadata.lastModified) ?? "-"}
/>
<InspectorItem
label="Memory"
value={
getNumberMetadata(resource.metadata.memorySize) === null
? "-"
: `${getNumberMetadata(resource.metadata.memorySize)} MB`
}
/>
<InspectorItem
label="Timeout"
value={
getNumberMetadata(resource.metadata.timeout) === null
? "-"
: `${getNumberMetadata(resource.metadata.timeout)}s`
}
/>
</div>
</section>
)}
<pre className="metadata-block">
{JSON.stringify(resource.metadata, null, 2)}
</pre>
<MetadataPanel metadata={resource.metadata} />
{isLogGroup && cloud && (
<>
<LogsQueryPanel cloud={cloud} logGroupName={resource.id} runtimeReachable={runtimeReachable ?? false} />
<LogsExplorerPanel cloud={cloud} resource={resource} runtimeReachable={runtimeReachable ?? false} />
</>
)}
</aside>
);
}
function EmailInspector({resource}: {resource: CloudResource}) {
const [tab, setTab] = useState<'preview' | 'text' | 'raw'>('preview')
const source = getStringMetadata(resource.metadata.source) ?? '-'
const toAddresses = getStringList(resource.metadata.toAddresses)
const ccAddresses = getStringList(resource.metadata.ccAddresses)
const bccAddresses = getStringList(resource.metadata.bccAddresses)
const replyToAddresses = getStringList(resource.metadata.replyToAddresses)
const textBody = getStringMetadata(resource.metadata.textBody)
const htmlBody = getStringMetadata(resource.metadata.htmlBody)
const rawData = getStringMetadata(resource.metadata.rawData)
const rawMime = rawData ? decodeRawMime(rawData) : null
const attachments = rawMime ? parseMimeAttachments(rawMime) : []
const inlineImages = rawMime ? inlineMimeImages(rawMime) : {}
const rawHtmlBody = rawMime ? htmlMimePart(rawMime) : null
const previewHtml = htmlBody ?? rawHtmlBody
const hasPreview = Boolean(previewHtml || textBody)
const activeTab = tab === 'preview' && !hasPreview ? (rawData ? 'raw' : 'text') : tab
return (
<aside className="resource-inspector">
<div className="widget-header">
<h3 title={resource.name}>{resource.name}</h3>
<span className="badge neutral">{getStringMetadata(resource.metadata.messageType) ?? 'email'}</span>
</div>
<div className="inspector-grid">
<InspectorItem label="From" value={source} />
<InspectorItem label="To" value={toAddresses.join(', ') || '-'} />
{ccAddresses.length > 0 && <InspectorItem label="Cc" value={ccAddresses.join(', ')} />}
{bccAddresses.length > 0 && <InspectorItem label="Bcc" value={bccAddresses.join(', ')} />}
{replyToAddresses.length > 0 && <InspectorItem label="Reply-To" value={replyToAddresses.join(', ')} />}
<InspectorItem label="Captured At" value={resource.createdAt ?? '-'} />
</div>
<section className="inspector-section">
<div className="drawer-tabs" style={{marginBottom: 10}}>
<button className={`drawer-tab ${activeTab === 'preview' ? 'active' : ''}`} disabled={!hasPreview} onClick={() => setTab('preview')}>Preview</button>
<button className={`drawer-tab ${activeTab === 'text' ? 'active' : ''}`} disabled={!textBody} onClick={() => setTab('text')}>Text</button>
<button className={`drawer-tab ${activeTab === 'raw' ? 'active' : ''}`} disabled={!rawData} onClick={() => setTab('raw')}>Raw</button>
{rawMime && <button className="button compact" type="button" onClick={() => downloadRawEmail(resource.id, rawMime)}>Download MIME</button>}
</div>
{activeTab === 'preview' && previewHtml ? (
<iframe
title="Email HTML preview"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={safeEmailDocument(previewHtml, inlineImages)}
style={{width: '100%', minHeight: 260, border: '1px solid var(--border)', borderRadius: 4, background: '#fff'}}
/>
) : activeTab === 'preview' ? (
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', margin: 0}}>{textBody ?? 'No preview content captured.'}</pre>
) : activeTab === 'text' ? (
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', margin: 0}}>{textBody ?? 'No text body captured.'}</pre>
) : (
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0}}>{rawMime ?? 'No raw MIME data captured.'}</pre>
)}
</section>
{attachments.length > 0 && <section className="inspector-section">
<p className="metric-label">MIME attachments</p>
{attachments.map((attachment, index) => <div className="apigateway-route-item" key={`${attachment.filename}-${index}`}>
<div><code>{attachment.filename ?? '(unnamed MIME part)'}</code><small>{attachment.contentType} · {attachment.disposition}</small>{attachment.contentId && <small>Content-ID: {attachment.contentId} ({attachment.disposition === 'inline' ? 'inline' : 'referenced part'})</small>}</div>
</div>)}
</section>}
</aside>
)
}
function downloadRawEmail(id: string, rawData: string): void {
const url = URL.createObjectURL(new Blob([rawData], {type: 'message/rfc822'}))
const link = document.createElement('a')
link.href = url
link.download = `${id || 'message'}.eml`
link.click()
URL.revokeObjectURL(url)
}
function safeEmailDocument(html: string, inlineImages: Record<string, string> = {}): string {
const withInlineImages = html.replace(/cid:([^"'\s>]+)/gi, (reference, contentId: string) => inlineImages[contentId] ?? reference)
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"></head><body>${withInlineImages}</body></html>`
}
function ProviderDatabaseSection({ cloud }: { cloud: string }) {
const label =
cloud === "azure"
? "Azure database"
: cloud === "gcp"
? "Google Cloud SQL"
: "Database";
return (
<section className="inspector-section">
<p className="metric-label">Lifecycle</p>
<p className="muted compact-text">
{label} lifecycle and snapshot actions are exposed through the
normalized resource metadata only.
</p>
</section>
);
}
function DatabaseConnectionsSection({
metadata,
engine,
status,
}: {
metadata: Record<string, unknown>;
engine?: string | null;
status?: string | null;
}) {
const endpoint = getRecordMetadata(metadata.endpoint);
const subnetGroup = getRecordMetadata(metadata.subnetGroup);
const securityGroups = getSecurityGroups(metadata.vpcSecurityGroups);
const address = getStringMetadata(endpoint?.address);
const port = getNumberMetadata(endpoint?.port);
const vpcId = getStringMetadata(subnetGroup?.vpcId);
const subnetGroupName = getStringMetadata(subnetGroup?.name);
const subnetGroupStatus = getStringMetadata(subnetGroup?.status);
return (
<section className="inspector-section">
<p className="metric-label">Connections</p>
<DatabaseConnectionHealth status={status} hasEndpoint={Boolean(address && port !== null)} />
<div className="inspector-grid compact-grid">
<InspectorItem label="Endpoint" value={address ?? "-"} />
<InspectorItem
label="Port"
value={port === null ? "-" : String(port)}
/>
<InspectorItem label="VPC" value={vpcId ?? "-"} />
<InspectorItem label="Subnet Group" value={subnetGroupName ?? "-"} />
<InspectorItem label="Subnet Status" value={subnetGroupStatus ?? "-"} />
<InspectorItem
label="Security Groups"
value={`${securityGroups.length}`}
/>
</div>
{address && port !== null && engine && <DatabaseConnectionCommand engine={engine} address={address} port={port} database={getStringMetadata(metadata.dbName) ?? (engine.includes('postgres') ? 'postgres' : '')} username={getStringMetadata(metadata.masterUsername) ?? (engine.includes('postgres') ? 'postgres' : 'root')} />}
{securityGroups.length > 0 && (
<div className="metadata-tags">
{securityGroups.map((group) => (
<span className="metadata-tag" key={`${group.id}:${group.status}`}>
<strong>{group.id}</strong>
<span>{group.status}</span>
</span>
))}
</div>
)}
</section>
);
}
function DatabaseConnectionHealth({status, hasEndpoint}: {status?: string | null; hasEndpoint: boolean}) {
const normalized = status?.toLowerCase()
const state = normalized === 'available' && hasEndpoint
? {label: 'Ready', detail: 'The provider reports this instance as available with a connection endpoint.'}
: normalized === 'failed'
? {label: 'Unavailable', detail: 'The provider reports the backing database as failed.'}
: !hasEndpoint
? {label: 'Endpoint unavailable', detail: 'The provider has not returned a usable database endpoint.'}
: {label: status ?? 'Unknown', detail: 'Connection readiness follows the provider-reported instance status.'}
return <p className="muted compact-text" role="status"><strong>Connection health: {state.label}.</strong> {state.detail}</p>
}
function DatabaseConnectionCommand({ engine, address, port, database, username }: { engine: string; address: string; port: number; database: string; username: string }) {
const host = address.startsWith('172.') ? 'localhost' : address
const command = engine.includes('postgres')
? `psql "host=${host} port=${port} dbname=${database} user=${username} password=<password>"`
: engine.includes('mysql') || engine.includes('mariadb')
? `mysql --host=${host} --port=${port} --user=${username} --password ${database}`
: `sqlcmd -S ${host},${port} -U ${username} -P <password> -d ${database}`
const uri = engine.includes('postgres')
? `postgresql://${username}:<password>@${host}:${port}/${database}`
: engine.includes('mysql') || engine.includes('mariadb')
? `mysql://${username}:<password>@${host}:${port}/${database}`
: `Server=${host},${port};Database=${database};User Id=${username};Password=<password>;`
return <div className="metadata-block"><p className="metric-label">Terminal connection</p><p className="copy-value mono">{command}<CopyValueButton value={command} label="Connection command" /></p><p className="metric-label">Client connection URI</p><p className="copy-value mono">{uri}<CopyValueButton value={uri} label="Connection URI" /></p><p className="muted compact-text">Paste the URI into a compatible database client, then replace the password placeholder. The password is never retained by the console.</p></div>
}
function DatabaseLifecycleSection({resource}: {resource: CloudResource}) {
const queryClient = useQueryClient()
const lifecycle = useMutation({
mutationFn: async (action: "start" | "stop" | "reboot") => {
if (action === "start") return startCloudResource(resource.cloud, resource.service, resource.id)
if (action === "stop") return stopCloudResource(resource.cloud, resource.service, resource.id)
return rebootCloudResource(resource.cloud, resource.service, resource.id)
},
onSuccess: () => {
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources(resource.cloud, resource.service)})
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resource(resource.cloud, resource.service, resource.id)})
},
})
const status = resource.status?.toLowerCase()
const isPending = lifecycle.isPending
return (
<section className="inspector-section">
<p className="metric-label">Lifecycle</p>
<div className="lifecycle-actions">
<button className="button compact" type="button" disabled={isPending || status !== "stopped"} onClick={() => lifecycle.mutate("start")}>
Start
</button>
<button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("stop")}>
Stop
</button>
<button className="button compact" type="button" disabled={isPending || status !== "available"} onClick={() => lifecycle.mutate("reboot")}>
Reboot
</button>
</div>
<p className="muted compact-text">
Current status: {resource.status ?? "unknown"}.
</p>
{lifecycle.error && <p className="muted compact-text">{lifecycle.error instanceof Error ? lifecycle.error.message : "Unable to update DB instance lifecycle."}</p>}
</section>
);
}
function InspectorItem({ label, value, copyable = false }: { label: string; value: string; copyable?: boolean }) {
return (
<div>
<p className="metric-label">{label}</p>
<p className="metric-value mono copy-value">
{value}
{copyable && value !== "-" && <CopyValueButton value={value} label={label} />}
</p>
</div>
);
}
function MetadataPanel({ metadata }: { metadata: Record<string, unknown> }) {
const rows = Object.entries(metadata).filter(
([, value]) => value !== undefined && value !== null && value !== "",
);
if (rows.length === 0) {
return (
<div className="metadata-block empty-metadata">
<span>No metadata returned</span>
</div>
);
}
return (
<div className="metadata-block metadata-table">
{rows.map(([key, value]) => (
<div key={key}>
<span>{humanizeKey(key)}</span>
<span className="copy-value">
<code>{formatMetadataValue(value)}</code>
{isCopyableMetadata(key, value) && <CopyValueButton value={formatMetadataValue(value)} label={humanizeKey(key)} />}
</span>
</div>
))}
</div>
);
}
function isCopyableMetadata(key: string, value: unknown): value is string | number {
return (typeof value === "string" || typeof value === "number") && /id$|arn|ip|endpoint|url|uri|key|hash|token/i.test(key);
}
function formatMetadataValue(value: unknown): string {
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function humanizeKey(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function getStringMetadata(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function getStringList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function getBooleanMetadata(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function getNumberMetadata(value: unknown): number | null {
return typeof value === "number" ? value : null;
}
function getRecordMetadata(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function getTags(value: unknown): Array<{ key: string; value: string }> {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const tag = item as Record<string, unknown>;
return typeof tag.key === "string" && typeof tag.value === "string"
? [{ key: tag.key, value: tag.value }]
: [];
});
}
function getSecurityGroups(
value: unknown,
): Array<{ id: string; status: string }> {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const group = item as Record<string, unknown>;
return typeof group.id === "string"
? [
{
id: group.id,
status: typeof group.status === "string" ? group.status : "-",
},
]
: [];
});
}