Adds GET /api/clouds/:cloud/diagnostics and a Diagnostics page in the General nav. The report combines, in one call: - runtime reachability, latency, version and edition from /_floci/health - every service the runtime emulates, with the console pages backed by it - per-console-service probes (the existing status check) - EC2 instance containers from /_floci/diagnose - console uptime, sign-in mode and active session count Each source is optional, so a down runtime still yields a partial report.
This commit is contained in:
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
|
||||
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
||||
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
||||
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
||||
import {DiagnosticsPage} from '@/pages/DiagnosticsPage'
|
||||
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
|
||||
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
|
||||
|
||||
@@ -18,6 +19,10 @@ export default function App() {
|
||||
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
|
||||
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
|
||||
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
||||
<Route path="/diagnostics" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||
<Route path="/diagnostics/azure" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||
<Route path="/diagnostics/gcp" element={<Navigate to="/diagnostics/aws" replace/>}/>
|
||||
<Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/>
|
||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
|
||||
@@ -13,6 +13,7 @@ export const apiEndpointKeys = {
|
||||
list: "clouds.list",
|
||||
services: "clouds.services.list",
|
||||
status: "clouds.status.get",
|
||||
diagnostics: "clouds.diagnostics.get",
|
||||
serviceStatus: "clouds.services.status.get",
|
||||
schema: "clouds.services.schema.get",
|
||||
dependencies: "clouds.services.dependencies.get",
|
||||
@@ -323,6 +324,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.diagnostics,
|
||||
{
|
||||
path: "/clouds/:cloud/diagnostics",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.serviceStatus,
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CloudServiceStatus,
|
||||
CloudServiceType,
|
||||
CloudStatus,
|
||||
RuntimeDiagnostics,
|
||||
} from "@/types/cloud";
|
||||
import type {
|
||||
AppConfigConfigurationProfile,
|
||||
@@ -86,6 +87,18 @@ export async function getCloudStatus(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCloudDiagnostics(
|
||||
cloud: CloudProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RuntimeDiagnostics> {
|
||||
const res = await apiClient.call<RuntimeDiagnostics>(
|
||||
apiEndpointKeys.clouds.diagnostics,
|
||||
requestOptions(cloud, "cloud-proxy", { signal }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCloudServiceStatus(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {
|
||||
getCloudDiagnostics,
|
||||
getCloudResource,
|
||||
getCloudServiceStatus,
|
||||
getCloudStatus,
|
||||
@@ -20,6 +21,7 @@ export const cloudQueryKeys = {
|
||||
clouds: ['clouds'] as const,
|
||||
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
||||
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
||||
diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const,
|
||||
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||
['cloud-service-status', cloud, service] as const,
|
||||
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||
@@ -53,6 +55,15 @@ export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInter
|
||||
})
|
||||
}
|
||||
|
||||
/** Full runtime report; polled slowly because it probes every console service. */
|
||||
export function useCloudDiagnosticsQuery(cloud: CloudProvider) {
|
||||
return useQuery({
|
||||
queryKey: cloudQueryKeys.diagnostics(cloud),
|
||||
queryFn: ({signal}) => getCloudDiagnostics(cloud, signal),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
/** Health of the single service being viewed, rather than of the whole cloud. */
|
||||
export function useCloudServiceStatusQuery(
|
||||
cloud: CloudProvider,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
||||
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
|
||||
import {Activity, AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import theCloud from '../../../../TheCloud.png'
|
||||
import {useTheme} from '@/lib/useTheme'
|
||||
@@ -184,6 +184,7 @@ export function Layout() {
|
||||
<div className="nav-section">
|
||||
<span className="nav-label">General</span>
|
||||
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
|
||||
<NavItem to={`/diagnostics/${activeCloud}`} icon={<Activity size={18} aria-hidden="true"/>} label="Diagnostics" collapsed={collapsed}/>
|
||||
</div>
|
||||
<CloudServiceNav collapsed={collapsed}/>
|
||||
</nav>
|
||||
@@ -356,6 +357,6 @@ function TopbarSearch() {
|
||||
}
|
||||
|
||||
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
||||
const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/)
|
||||
const match = pathname.match(/^\/(?:cloud-explorer|console|diagnostics)\/(aws|azure|gcp)(?:\/|$)/)
|
||||
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
|
||||
}
|
||||
|
||||
@@ -4261,6 +4261,104 @@ pre,
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.diagnostics .table-panel {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.diagnostics-section-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.diagnostics-section-header h2 {
|
||||
margin: 0;
|
||||
color: var(--text-bright);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.diagnostics-filters,
|
||||
.diagnostics-filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.diagnostics-filters .input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.diagnostics-service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.diagnostics-service {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
min-width: 0;
|
||||
padding: 9px 16px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diagnostics-page-link {
|
||||
padding: 1px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--link);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.diagnostics-page-link:hover {
|
||||
border-color: var(--link);
|
||||
}
|
||||
|
||||
.diagnostics-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.diagnostics-facts div {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.diagnostics-facts dt {
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.diagnostics-facts dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.diagnostics-filters .input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.console-provider-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {DiagnosticsPage} from './DiagnosticsPage'
|
||||
import type {RuntimeDiagnostics} from '@/types/cloud'
|
||||
|
||||
const api = vi.hoisted(() => ({getCloudDiagnostics: vi.fn()}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
|
||||
const healthy: RuntimeDiagnostics = {
|
||||
cloud: 'aws',
|
||||
checkedAt: new Date().toISOString(),
|
||||
runtime: {status: 'reachable', endpoint: 'http://floci:4566', latencyMs: 12, error: null, version: '1.9.0', edition: 'community'},
|
||||
emulatedServices: [
|
||||
{name: 'route53', state: 'running', consolePages: []},
|
||||
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
|
||||
{name: 'sqs', state: 'stopped', consolePages: [{service: 'messaging', displayName: 'SQS', route: '/cloud-explorer/aws/messaging'}]},
|
||||
],
|
||||
consoleServices: [
|
||||
{cloud: 'aws', service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 4, error: null, errorCode: null},
|
||||
{cloud: 'aws', service: 'messaging', adapterRegistered: true, runtime: 'unavailable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 9, error: 'Service is not running', errorCode: 'RUNTIME_UNAVAILABLE'},
|
||||
],
|
||||
containers: [{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201}],
|
||||
console: {startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9_015, authMode: 'oidc', activeSessions: 2},
|
||||
}
|
||||
|
||||
function renderPage(path = '/diagnostics/aws') {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes><Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/></Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
test('summarises the runtime and lists failing console services first', async () => {
|
||||
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||
renderPage()
|
||||
|
||||
const summary = await screen.findByRole('region', {name: 'Runtime summary'})
|
||||
expect(within(summary).getByText('Reachable')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://floci:4566 · 12 ms')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.9.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 not running')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row')
|
||||
expect(rows[1]).toHaveTextContent('messaging')
|
||||
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
|
||||
|
||||
expect(screen.getByRole('link', {name: 'i-0abc'})).toHaveAttribute('href', '/cloud-explorer/aws/compute?resource=i-0abc')
|
||||
expect(screen.getByText('localhost:2201')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 h 30 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('filters emulated services by console coverage and state', async () => {
|
||||
const user = userEvent.setup()
|
||||
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||
renderPage()
|
||||
const section = await screen.findByRole('region', {name: 'Emulated services'})
|
||||
|
||||
await user.click(within(section).getByRole('button', {name: 'No console page'}))
|
||||
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['route53'])
|
||||
|
||||
await user.click(within(section).getByRole('button', {name: 'Not running'}))
|
||||
expect(within(section).getByRole('link', {name: 'SQS'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
|
||||
expect(within(section).getAllByRole('listitem')).toHaveLength(1)
|
||||
|
||||
await user.click(within(section).getByRole('button', {name: 'All'}))
|
||||
await user.type(within(section).getByRole('searchbox'), 's3')
|
||||
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['s3S3'])
|
||||
})
|
||||
|
||||
test('shows what is known when the runtime is down', async () => {
|
||||
api.getCloudDiagnostics.mockResolvedValue({
|
||||
...healthy,
|
||||
runtime: {status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null},
|
||||
emulatedServices: null,
|
||||
consoleServices: [],
|
||||
containers: null,
|
||||
} satisfies RuntimeDiagnostics)
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Unavailable')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cannot reach Floci core at http://floci:4566')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Not reported by the runtime')).toHaveLength(2)
|
||||
expect(screen.queryByRole('region', {name: 'Emulated services'})).not.toBeInTheDocument()
|
||||
expect(screen.getByText('This runtime does not publish container diagnostics.')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {Activity, RefreshCw} from 'lucide-react'
|
||||
import {Link, Navigate, useParams} from 'react-router-dom'
|
||||
import {useCloudDiagnosticsQuery} from '@/api/queries/cloudQueries'
|
||||
import {formatDateTime, formatRelativeTime} from '@/lib/format'
|
||||
import type {CloudProvider, CloudServiceStatus, EmulatedServiceStatus, RuntimeDiagnostics} from '@/types/cloud'
|
||||
|
||||
type ServiceFilter = 'all' | 'not-running' | 'with-page' | 'without-page'
|
||||
|
||||
const SERVICE_FILTERS: Array<{id: ServiceFilter; label: string}> = [
|
||||
{id: 'all', label: 'All'},
|
||||
{id: 'not-running', label: 'Not running'},
|
||||
{id: 'with-page', label: 'Has console page'},
|
||||
{id: 'without-page', label: 'No console page'},
|
||||
]
|
||||
|
||||
export function DiagnosticsPage() {
|
||||
const params = useParams()
|
||||
const cloud = normalizeCloud(params.cloud)
|
||||
const query = useCloudDiagnosticsQuery(cloud ?? 'aws')
|
||||
|
||||
if (!cloud) return <Navigate to="/diagnostics/aws" replace/>
|
||||
|
||||
const report = query.data
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Activity size={20}/>
|
||||
<div>
|
||||
<h1>Diagnostics</h1>
|
||||
<p className="muted">{report ? `Checked ${formatRelativeTime(report.checkedAt) ?? 'just now'}` : 'Runtime, services and console health'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="button" type="button" disabled={query.isFetching} onClick={() => void query.refetch()}>
|
||||
<RefreshCw size={14}/>{query.isFetching ? 'Checking' : 'Re-check'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="content diagnostics">
|
||||
{query.isLoading && <p className="muted padded">Running diagnostics</p>}
|
||||
{query.error && <p className="error-text">Could not load diagnostics: {query.error.message}</p>}
|
||||
{report && <DiagnosticsReport report={report}/>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
|
||||
const emulated = report.emulatedServices
|
||||
const notRunning = emulated?.filter((service) => service.state !== 'running').length ?? 0
|
||||
const covered = emulated?.filter((service) => service.consolePages.length > 0).length ?? 0
|
||||
const reachable = report.runtime.status === 'reachable'
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="console-summary" aria-label="Runtime summary">
|
||||
<div className={`summary-tile ${reachable ? 'ready' : 'unavailable'}`}>
|
||||
<span>Runtime</span>
|
||||
<strong>{reachable ? 'Reachable' : 'Unavailable'}</strong>
|
||||
<small>{report.runtime.endpoint ?? 'No endpoint'}{report.runtime.latencyMs !== null ? ` · ${report.runtime.latencyMs} ms` : ''}</small>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<span>Version</span>
|
||||
<strong>{report.runtime.version ?? 'Unknown'}</strong>
|
||||
<small>{report.runtime.edition ? `${report.runtime.edition} edition` : 'Not reported by the runtime'}</small>
|
||||
</div>
|
||||
<div className={`summary-tile ${notRunning > 0 ? 'pending' : ''}`}>
|
||||
<span>Emulated services</span>
|
||||
<strong>{emulated ? emulated.length : '—'}</strong>
|
||||
<small>{emulated ? (notRunning > 0 ? `${notRunning} not running` : 'All running') : 'Not reported by the runtime'}</small>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<span>Console coverage</span>
|
||||
<strong>{emulated ? `${covered} / ${emulated.length}` : `${report.consoleServices.length}`}</strong>
|
||||
<small>{emulated ? 'Emulated services with a console page' : 'Console services'}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{report.runtime.error && <p className="error-text">{report.runtime.error}</p>}
|
||||
|
||||
<ConsoleServicesTable services={report.consoleServices}/>
|
||||
{emulated && <EmulatedServices services={emulated}/>}
|
||||
<ContainersTable report={report}/>
|
||||
<ConsoleProcess report={report}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
||||
const failing = services.filter((service) => service.runtime !== 'reachable')
|
||||
const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')]
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-console-services">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-console-services">Console services</h2>
|
||||
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`}</span>
|
||||
</header>
|
||||
{services.length === 0
|
||||
? <p className="muted padded">No console services are registered for this cloud.</p>
|
||||
: <table className="table">
|
||||
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
|
||||
<tbody>{ordered.map((service) => (
|
||||
<tr key={service.service}>
|
||||
<td><code>{service.service}</code></td>
|
||||
<td><span className={`status ${statusClass(service.runtime)}`}>{statusLabel(service.runtime)}</span></td>
|
||||
<td>{service.latencyMs === null ? '—' : `${service.latencyMs} ms`}</td>
|
||||
<td>{service.error ? <span className="error-text compact-text">{service.error}{service.errorCode ? ` (${service.errorCode})` : ''}</span> : <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EmulatedServices({services}: {services: EmulatedServiceStatus[]}) {
|
||||
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const visible = useMemo(() => {
|
||||
const term = search.trim().toLowerCase()
|
||||
return services.filter((service) => {
|
||||
if (term && !service.name.includes(term) && !service.consolePages.some((page) => page.displayName.toLowerCase().includes(term))) return false
|
||||
if (filter === 'not-running') return service.state !== 'running'
|
||||
if (filter === 'with-page') return service.consolePages.length > 0
|
||||
if (filter === 'without-page') return service.consolePages.length === 0
|
||||
return true
|
||||
})
|
||||
}, [services, filter, search])
|
||||
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-emulated-services">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-emulated-services">Emulated services</h2>
|
||||
<div className="diagnostics-filters">
|
||||
<div className="diagnostics-filter-group" role="group" aria-label="Filter emulated services">
|
||||
{SERVICE_FILTERS.map((option) => (
|
||||
<button key={option.id} className={`button compact ${filter === option.id ? 'primary' : ''}`} type="button" aria-pressed={filter === option.id} onClick={() => setFilter(option.id)}>{option.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="input" type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Filter services" aria-label="Filter emulated services by name"/>
|
||||
</div>
|
||||
</header>
|
||||
{visible.length === 0
|
||||
? <p className="muted padded">No emulated services match this filter.</p>
|
||||
: <ul className="diagnostics-service-grid">{visible.map((service) => (
|
||||
<li key={service.name} className="diagnostics-service">
|
||||
<span className={`dot ${service.state === 'running' ? 'healthy' : 'unavailable'}`} aria-hidden="true"/>
|
||||
<code>{service.name}</code>
|
||||
{service.state !== 'running' && <span className="muted">{service.state}</span>}
|
||||
{service.consolePages.map((page) => <Link key={page.service} className="diagnostics-page-link" to={page.route}>{page.displayName}</Link>)}
|
||||
</li>
|
||||
))}</ul>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ContainersTable({report}: {report: RuntimeDiagnostics}) {
|
||||
const containers = report.containers
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-containers">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-containers">Runtime containers</h2>
|
||||
<span className="muted">Containers started for EC2 instances</span>
|
||||
</header>
|
||||
{containers === null && <p className="muted padded">This runtime does not publish container diagnostics.</p>}
|
||||
{containers?.length === 0 && <p className="muted padded">No EC2 instance containers are running.</p>}
|
||||
{containers && containers.length > 0 && <table className="table">
|
||||
<thead><tr><th>Instance</th><th>Container</th><th>SSH</th></tr></thead>
|
||||
<tbody>{containers.map((container) => (
|
||||
<tr key={container.resourceId}>
|
||||
<td><Link to={`/cloud-explorer/${report.cloud}/compute?resource=${encodeURIComponent(container.resourceId)}`}><code>{container.resourceId}</code></Link></td>
|
||||
<td>{container.status ?? '—'}</td>
|
||||
<td>{container.sshPort ? <code>{container.sshHost ?? 'localhost'}:{container.sshPort}</code> : '—'}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsoleProcess({report}: {report: RuntimeDiagnostics}) {
|
||||
return (
|
||||
<section className="table-panel" aria-labelledby="diagnostics-console">
|
||||
<header className="diagnostics-section-header">
|
||||
<h2 id="diagnostics-console">Console</h2>
|
||||
</header>
|
||||
<dl className="diagnostics-facts">
|
||||
<div><dt>Uptime</dt><dd>{formatUptime(report.console.uptimeSeconds)}</dd></div>
|
||||
<div><dt>Started</dt><dd>{formatDateTime(report.console.startedAt) ?? report.console.startedAt}</dd></div>
|
||||
<div><dt>Sign-in</dt><dd>{report.console.authMode === 'oidc' ? 'OIDC single sign-on' : 'Local administrator'}</dd></div>
|
||||
<div><dt>Active sessions</dt><dd>{report.console.activeSessions}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatUptime(totalSeconds: number): string {
|
||||
const days = Math.floor(totalSeconds / 86_400)
|
||||
const hours = Math.floor((totalSeconds % 86_400) / 3_600)
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60)
|
||||
if (days > 0) return `${days} d ${hours} h`
|
||||
if (hours > 0) return `${hours} h ${minutes} min`
|
||||
if (minutes > 0) return `${minutes} min`
|
||||
return `${totalSeconds} s`
|
||||
}
|
||||
|
||||
function statusClass(runtime: CloudServiceStatus['runtime']): string {
|
||||
if (runtime === 'reachable') return 'healthy'
|
||||
if (runtime === 'coming_soon') return 'degraded'
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
function statusLabel(runtime: CloudServiceStatus['runtime']): string {
|
||||
if (runtime === 'reachable') return 'Reachable'
|
||||
if (runtime === 'coming_soon') return 'Not registered'
|
||||
return 'Unavailable'
|
||||
}
|
||||
|
||||
function normalizeCloud(value?: string): CloudProvider | null {
|
||||
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
|
||||
}
|
||||
@@ -83,3 +83,39 @@ export interface CloudServiceStatus {
|
||||
/** Mapped error code, so "not implemented" reads differently from "down". */
|
||||
errorCode: string | null
|
||||
}
|
||||
|
||||
export interface EmulatedServiceStatus {
|
||||
name: string
|
||||
state: string
|
||||
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
|
||||
}
|
||||
|
||||
export interface RuntimeContainer {
|
||||
kind: 'ec2-instance'
|
||||
resourceId: string
|
||||
status: string | null
|
||||
sshHost: string | null
|
||||
sshPort: number | null
|
||||
}
|
||||
|
||||
export interface RuntimeDiagnostics {
|
||||
cloud: CloudProvider
|
||||
checkedAt: string
|
||||
runtime: {
|
||||
status: RuntimeReachability
|
||||
endpoint: string | null
|
||||
latencyMs: number | null
|
||||
error: string | null
|
||||
version: string | null
|
||||
edition: string | null
|
||||
}
|
||||
emulatedServices: EmulatedServiceStatus[] | null
|
||||
consoleServices: CloudServiceStatus[]
|
||||
containers: RuntimeContainer[] | null
|
||||
console: {
|
||||
startedAt: string
|
||||
uptimeSeconds: number
|
||||
authMode: 'local' | 'oidc'
|
||||
activeSessions: number
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user