diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4fce0e..f84ab9d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { logout, readAuthSession, readDriverView, + readProductEnvironmentConfigStatus, } from "./api"; import { ApiErrorPanel, AuthPanel } from "./AuthPanels"; import { formatTime, labelForStatus } from "./format"; @@ -47,6 +48,7 @@ import { runtimeScopeForTarget, secretScopeForTarget, } from "./ProductConfigPanel"; +import { ProductConfigStatusPanel } from "./ProductConfigStatusPanel"; import { RuntimeAuthorityList } from "./RuntimeAuthorityList"; import { StatusIcon, StatusPill, StateBlock, SkeletonRows } from "./status-ui"; import { TrustBadge } from "./TrustBadge"; @@ -71,6 +73,7 @@ import type { LaneSummary, ProductConfigApplyPayload, ProductConfigApplyRequest, + ProductEnvironmentConfigStatus, ProductEnvironmentSummary, ProductProfileRecord, ProductSiteOverview, @@ -206,6 +209,11 @@ export function App() { const [workRequests, setWorkRequests] = useState< EveryCodeWorkRequestRecord[] >([]); + const [configStatuses, setConfigStatuses] = useState< + ProductEnvironmentConfigStatus[] + >([]); + const [configStatusLoading, setConfigStatusLoading] = useState(false); + const [configStatusError, setConfigStatusError] = useState(""); const { workGraphItems, workGraphHiddenCount, @@ -221,6 +229,10 @@ export function App() { productProfiles, productOverviews, }); + const selectedProductOverview = + productOverviews.find( + (overview) => overview.product === selected.driverId, + ) ?? null; const [prodView, setProdView] = useState(null); const [testingView, setTestingView] = useState( null, @@ -277,6 +289,8 @@ export function App() { setDrivers([]); setProductProfiles([]); setProductOverviews([]); + setConfigStatuses([]); + setConfigStatusError(""); setWorkRequests([]); resetWorkGraph(); setProdView(null); @@ -361,6 +375,53 @@ export function App() { return () => controller.abort(); }, [authStatus, selected, refreshKey, refreshWorkGraph, resetWorkGraph]); + useEffect(() => { + if (authStatus !== "signed_in" || !selectedProductOverview) { + setConfigStatuses([]); + setConfigStatusError(""); + setConfigStatusLoading(false); + return; + } + const controller = new AbortController(); + const environments = selectedProductOverview.environments.filter( + (environment) => environment.environment.trim(), + ); + setConfigStatusLoading(true); + setConfigStatusError(""); + Promise.all( + environments.map((environment) => + readProductEnvironmentConfigStatus( + selectedProductOverview.product, + environment.environment, + ).then((payload) => payload.config_status), + ), + ) + .then((statuses) => { + if (!controller.signal.aborted) { + setConfigStatuses(statuses); + } + }) + .catch((apiError: unknown) => { + if (controller.signal.aborted) { + return; + } + setConfigStatuses([]); + if (apiError instanceof LaunchplaneApiError) { + setConfigStatusError(apiError.message); + } else if (apiError instanceof Error) { + setConfigStatusError(apiError.message); + } else { + setConfigStatusError("Config status request failed."); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setConfigStatusLoading(false); + } + }); + return () => controller.abort(); + }, [authStatus, selectedProductOverview, refreshKey]); + const currentDriver = drivers.find( (driver) => driver.driver_id === selected.driverId, ); @@ -371,11 +432,6 @@ export function App() { prodDriverView?.descriptor ?? testingDriverView?.descriptor ?? currentDriver; - const selectedProductOverview = - productOverviews.find( - (overview) => overview.product === selected.driverId, - ) ?? null; - const actions = useMemo(() => { return ( selectedDriver?.actions ?? @@ -402,6 +458,8 @@ export function App() { setDrivers([]); setProductProfiles([]); setProductOverviews([]); + setConfigStatuses([]); + setConfigStatusError(""); setWorkRequests([]); resetWorkGraph(); setProdView(null); @@ -485,6 +543,11 @@ export function App() { loading={loading} /> + +
+ +
+ + + {totals.configured}/{totals.total} +
+ } + /> + {error ? ( +
+ Config status unavailable + {error} +
+ ) : null} + {loading && !statuses.length ? : null} + {!loading && !statuses.length && !error ? ( + } title="No expected config status" /> + ) : null} + {statuses.map((status) => ( + + ))} + + ); +} + +function EnvironmentConfigStatus({ + status, +}: { + status: ProductEnvironmentConfigStatus; +}) { + const runtimeItems = status.runtime_settings; + const secretItems = status.managed_secrets; + return ( +
+
+
+ {status.environment} + {status.context} +
+ + {freshnessLabel(status.trust_state)} + +
+
+ ); +} + +function ConfigStatusGroup({ + title, + emptyTitle, + icon, + items, +}: { + title: string; + emptyTitle: string; + icon: ReactNode; + items: ConfigStatusRowItem[]; +}) { + return ( +
+
+ {icon} + {title} +
+
+ {items.length ? ( + items.map((item) => ) + ) : ( + + )} +
+
+ ); +} + +type ConfigStatusRowItem = { + id: string; + name: string; + status: ProductConfigItemStatus; + route: string; + detail: string; + updatedAt: string; +}; + +function ConfigStatusRow({ item }: { item: ConfigStatusRowItem }) { + return ( +
+ +
+ {item.name} + {labelForStatus(item.status)} +
+ {item.route} + {item.updatedAt ? formatTime(item.updatedAt) : item.detail} +
+ ); +} + +function secretStatusRow( + item: ProductManagedSecretConfigStatusItem, +): ConfigStatusRowItem { + return { + id: `${item.integration}:${item.context}:${item.instance}:${item.binding_key}`, + name: item.binding_key, + status: item.status, + route: configRouteLabel(item.context, item.instance), + detail: item.integration, + updatedAt: item.updated_at, + }; +} + +function summarizeConfigStatuses(statuses: ProductEnvironmentConfigStatus[]) { + const items = statuses.flatMap((status) => [ + ...status.runtime_settings.map((item) => item.status), + ...status.managed_secrets.map((item) => item.status), + ]); + const configured = items.filter((status) => status === "configured").length; + return { + configured, + total: items.length, + worst: worstConfigStatus(items), + }; +} + +function worstConfigStatus( + statuses: ProductConfigItemStatus[], +): ProductConfigItemStatus { + if (statuses.some((status) => status === "missing" || status === "disabled")) { + return "missing"; + } + if (statuses.some((status) => status === "stale" || status === "unvalidated")) { + return "stale"; + } + if (statuses.some((status) => status === "unsupported")) { + return "unsupported"; + } + if (statuses.some((status) => status === "configured")) { + return "configured"; + } + return "unsupported"; +} + +function statusToUiStatus(status: ProductConfigItemStatus): string { + if (status === "configured") { + return "pass"; + } + if (status === "stale" || status === "unvalidated") { + return "pending"; + } + if (status === "unsupported") { + return "unknown"; + } + return "blocked"; +} + +function configRouteLabel(context: string, instance: string): string { + if (context && instance) { + return `${context}/${instance}`; + } + return context || "global"; +} + +function ConfigStatusSkeleton() { + return ( + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5744f39..6714d4f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -11,6 +11,7 @@ import type { LogoutPayload, ProductConfigApplyPayload, ProductConfigApplyRequest, + ProductEnvironmentConfigStatusPayload, ProductListPayload, ProductProfileListPayload, WorkGraphRankPayload, @@ -98,6 +99,15 @@ export function listProducts(): Promise { return requestJson("/v1/products"); } +export function readProductEnvironmentConfigStatus( + product: string, + environment: string, +): Promise { + return requestJson( + `/v1/products/${encodeURIComponent(product)}/environments/${encodeURIComponent(environment)}/config-status`, + ); +} + export function listEveryCodeWorkRequests( limit = 8, ): Promise { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index eef997c..efaa1a7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1075,6 +1075,145 @@ p { display: grid; } +.config-status-panel { + display: grid; +} + +.config-status-environment { + display: grid; + grid-template-columns: minmax(180px, 0.42fr) minmax(0, 1fr) minmax(0, 1fr); + min-width: 0; + border-top: 1px solid var(--hair-soft); +} + +.config-status-environment:first-of-type { + border-top: 0; +} + +.config-status-environment[data-environment="prod"] { + border-left: 3px solid var(--lane-prod); +} + +.config-status-environment[data-environment="testing"] { + border-left: 3px solid var(--lane-testing); +} + +.config-status-environment-head, +.config-status-group { + min-width: 0; + border-left: 1px solid var(--hair-soft); + padding: 12px 14px; +} + +.config-status-environment-head { + display: grid; + align-content: start; + gap: 10px; + border-left: 0; +} + +.config-status-environment-head div, +.config-status-group { + display: grid; + gap: 8px; +} + +.config-status-environment-head strong { + color: var(--fg-strong); + text-transform: capitalize; +} + +.config-status-environment-head code, +.config-status-row code, +.config-status-row strong, +.config-status-row span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-status-environment-head code { + color: var(--fg-faint); +} + +.config-status-group-title { + display: flex; + align-items: center; + gap: 7px; + min-height: 24px; + color: var(--fg-muted); + font-weight: 650; +} + +.config-status-list { + display: grid; + gap: 6px; +} + +.config-status-row { + display: grid; + grid-template-columns: max-content minmax(120px, 0.75fr) minmax(0, 1fr) minmax(90px, auto); + gap: 9px; + align-items: center; + min-height: 34px; + border: 1px solid var(--hair-soft); + border-radius: 6px; + background: var(--bg-0); + padding: 6px 8px; +} + +.config-status-row > div { + display: grid; + min-width: 0; + gap: 1px; +} + +.config-status-row strong { + color: var(--fg); + font-family: var(--font-mono); + font-size: 11px; +} + +.config-status-row span { + color: var(--fg-faint); + font-size: 11px; +} + +.config-status-row > span:last-child { + text-align: right; +} + +.config-status-alert { + display: grid; + grid-template-columns: minmax(180px, 0.36fr) minmax(0, 1fr); + gap: 10px; + align-items: center; + border-top: 1px solid color-mix(in oklab, var(--status-warn) 52%, var(--hair)); + color: var(--status-warn); + padding: 10px 14px; +} + +.config-status-alert code { + min-width: 0; + overflow: hidden; + color: var(--fg-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-status-skeleton { + display: grid; + gap: 8px; + padding: 12px 14px; +} + +.config-status-skeleton span { + height: 34px; + border-radius: 6px; + background: linear-gradient(90deg, var(--bg-2), var(--bg-3), var(--bg-2)); +} + .config-target-grid, .config-edit-grid { display: grid; @@ -1720,10 +1859,16 @@ p { .product-overview-grid, .work-grid, .work-grid-evidence, + .config-status-environment, .config-edit-grid { grid-template-columns: minmax(0, 1fr); } + .config-status-group { + border-top: 1px solid var(--hair-soft); + border-left: 0; + } + .product-environment-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1798,6 +1943,8 @@ p { .preview-row, .secret-row, .evidence-row, + .config-status-row, + .config-status-alert, .config-target-grid, .config-row-runtime, .config-row-secret, @@ -1819,7 +1966,8 @@ p { } .preview-row .status-pill, - .secret-row .status-pill { + .secret-row .status-pill, + .config-status-row .status-pill { width: max-content; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 31c2cbe..ae15717 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -454,6 +454,56 @@ export interface ProductSiteOverview { available_actions: ProductActionAvailability[]; } +export type ProductConfigItemStatus = + | "configured" + | "missing" + | "disabled" + | "unvalidated" + | "stale" + | "unsupported"; + +export interface ProductRuntimeConfigStatusItem { + key: string; + status: ProductConfigItemStatus; + context: string; + instance: string; + source_label: string; + updated_at: string; + trust_state: FreshnessStatus; +} + +export interface ProductManagedSecretConfigStatusItem { + binding_key: string; + status: ProductConfigItemStatus; + integration: string; + context: string; + instance: string; + updated_at: string; + trust_state: FreshnessStatus | "disabled"; +} + +export interface ProductEnvironmentConfigStatus { + schema_version: number; + product: string; + display_name: string; + repository: string; + driver_id: string; + base_driver_id: string; + environment: string; + context: string; + runtime_settings: ProductRuntimeConfigStatusItem[]; + managed_secrets: ProductManagedSecretConfigStatusItem[]; + warnings: string[]; + trust_state: FreshnessStatus; + provenance: DataProvenance; +} + +export interface ProductEnvironmentConfigStatusPayload { + status: "ok"; + trace_id: string; + config_status: ProductEnvironmentConfigStatus; +} + export interface ProductListPayload { status: "ok"; trace_id: string;