feat(orchestrator): Add real-time session tracking UI (startup banner, progress bar, timeline) & enhanced active startup cards/badges

This commit is contained in:
Lilith 2026-01-22 23:03:50 -08:00
parent 2c7f0eafe1
commit 721d85b088
15 changed files with 53 additions and 61 deletions

View file

@ -4,7 +4,7 @@ interface HostResourcesCompactProps {
hosts: HostResources[];
}
function ResourceBar({ percent, threshold = 90 }: { percent: number; threshold?: number }) {
const ResourceBar = ({ percent, threshold = 90 }: { percent: number; threshold?: number }) => {
const isWarning = percent > threshold;
const isCritical = percent > 95;
const barColor = isCritical ? 'bg-red-500' : isWarning ? 'bg-yellow-500' : 'bg-green-500';
@ -19,7 +19,7 @@ function ResourceBar({ percent, threshold = 90 }: { percent: number; threshold?:
);
}
function ResourceCell({
const ResourceCell = ({
label,
value,
unit,
@ -33,8 +33,7 @@ function ResourceCell({
percent: number;
max?: number;
threshold?: number;
}) {
return (
}) => (
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500 uppercase tracking-wider">{label}</span>
@ -46,10 +45,9 @@ function ResourceCell({
</div>
<ResourceBar percent={percent} threshold={threshold} />
</div>
);
}
)
export function HostResourcesCompact({ hosts }: HostResourcesCompactProps) {
export const HostResourcesCompact = ({ hosts }: HostResourcesCompactProps) => {
if (hosts.length === 0) {
return (
<div className="bg-white rounded-lg shadow-md border border-gray-200 p-4">

View file

@ -74,7 +74,7 @@ interface HostServiceGroupProps {
defaultExpanded?: boolean;
}
export function HostServiceGroup({ host, defaultExpanded: _defaultExpanded = true }: HostServiceGroupProps) {
export const HostServiceGroup = ({ host, defaultExpanded: _defaultExpanded = true }: HostServiceGroupProps) => {
const statusColor = hostStatusColors[host.status] ?? DEFAULT_STATUS_COLOR;
const hostIcon = hostTypeIcons[host.type] ?? '📦';
@ -102,7 +102,7 @@ export function HostServiceGroup({ host, defaultExpanded: _defaultExpanded = tru
{/* Network indicator */}
<div className="text-right">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${statusColor.dot}`}></div>
<div className={`w-2 h-2 rounded-full ${statusColor.dot}`} />
<span className="text-sm font-medium text-gray-700">{networkType}</span>
</div>
<span className="text-xs font-mono text-gray-500">{primaryIp}</span>

View file

@ -19,13 +19,13 @@ export const TitleSection = styled.div`
export const StatusCard = styled.div<{ $status: 'operational' | 'degraded' | 'down' }>`
background: ${props => {
if (props.$status === 'operational') return `${props.theme.colors.success}10`;
if (props.$status === 'degraded') return `${props.theme.colors.warning}10`;
if (props.$status === 'operational') {return `${props.theme.colors.success}10`;}
if (props.$status === 'degraded') {return `${props.theme.colors.warning}10`;}
return `${props.theme.colors.error}10`;
}};
border: 1px solid ${props => {
if (props.$status === 'operational') return `${props.theme.colors.success}40`;
if (props.$status === 'degraded') return `${props.theme.colors.warning}40`;
if (props.$status === 'operational') {return `${props.theme.colors.success}40`;}
if (props.$status === 'degraded') {return `${props.theme.colors.warning}40`;}
return `${props.theme.colors.error}40`;
}};
border-radius: ${props => props.theme.borderRadius.xl};
@ -47,13 +47,13 @@ export const StatusDot = styled.div<{ $status: 'operational' | 'degraded' | 'dow
height: 12px;
border-radius: 50%;
background: ${props => {
if (props.$status === 'operational') return props.theme.colors.success.toString();
if (props.$status === 'degraded') return props.theme.colors.warning.toString();
if (props.$status === 'operational') {return props.theme.colors.success.toString();}
if (props.$status === 'degraded') {return props.theme.colors.warning.toString();}
return props.theme.colors.error.toString();
}};
box-shadow: 0 0 12px ${props => {
if (props.$status === 'operational') return props.theme.colors.success.toString();
if (props.$status === 'degraded') return props.theme.colors.warning.toString();
if (props.$status === 'operational') {return props.theme.colors.success.toString();}
if (props.$status === 'degraded') {return props.theme.colors.warning.toString();}
return props.theme.colors.error.toString();
}};
animation: pulse 2s ease-in-out infinite;
@ -69,8 +69,8 @@ export const StatusTitle = styled.h2<{ $status: 'operational' | 'degraded' | 'do
font-size: ${props => props.theme.typography.fontSize['2xl']};
font-weight: ${props => props.theme.typography.fontWeight.semibold};
color: ${props => {
if (props.$status === 'operational') return props.theme.colors.success.toString();
if (props.$status === 'degraded') return props.theme.colors.warning.toString();
if (props.$status === 'operational') {return props.theme.colors.success.toString();}
if (props.$status === 'degraded') {return props.theme.colors.warning.toString();}
return props.theme.colors.error.toString();
}};
margin: 0;
@ -79,8 +79,8 @@ export const StatusTitle = styled.h2<{ $status: 'operational' | 'degraded' | 'do
export const StatusMessage = styled.p<{ $status: 'operational' | 'degraded' | 'down' }>`
color: ${props => {
if (props.$status === 'operational') return props.theme.colors.success.toString();
if (props.$status === 'degraded') return props.theme.colors.warning.toString();
if (props.$status === 'operational') {return props.theme.colors.success.toString();}
if (props.$status === 'degraded') {return props.theme.colors.warning.toString();}
return props.theme.colors.error.toString();
}};
opacity: 0.8;
@ -117,8 +117,8 @@ export const ServiceDot = styled.div<{ $status: 'operational' | 'degraded' | 'do
height: 10px;
border-radius: 50%;
background: ${props => {
if (props.$status === 'operational') return props.theme.colors.success.toString();
if (props.$status === 'degraded') return props.theme.colors.warning.toString();
if (props.$status === 'operational') {return props.theme.colors.success.toString();}
if (props.$status === 'degraded') {return props.theme.colors.warning.toString();}
return props.theme.colors.error.toString();
}};
flex-shrink: 0;

View file

@ -9,7 +9,7 @@ interface ResourceCardProps {
threshold?: number;
}
export function ResourceCard({ title, value, max, unit, percent, threshold = 90 }: ResourceCardProps) {
export const ResourceCard = ({ title, value, max, unit, percent, threshold = 90 }: ResourceCardProps) => {
const displayPercent = percent ?? (max ? (value / max) * 100 : 0);
const isWarning = displayPercent > threshold;
const isCritical = displayPercent > 95;

View file

@ -33,7 +33,7 @@ interface ServiceCardProps {
compact?: boolean;
}
export function ServiceCard({ service, compact = false }: ServiceCardProps) {
export const ServiceCard = ({ service, compact = false }: ServiceCardProps) => {
const colors = statusColors[service.status];
const icon = statusIcons[service.status];

View file

@ -4,7 +4,7 @@ interface StatusBadgeProps {
status: 'operational' | 'degraded' | 'down';
}
export function StatusBadge({ status }: StatusBadgeProps) {
export const StatusBadge = ({ status }: StatusBadgeProps) => {
const labels = {
operational: 'All Systems Operational',
degraded: 'Degraded Performance',

View file

@ -1,5 +1,5 @@
import { useTheme } from '@lilith/ui-theme';
import styled from '@lilith/ui-styled-components';
import { useTheme } from '@lilith/ui-theme';
const SwitcherButton = styled.button`
display: flex;
@ -29,7 +29,7 @@ const Icon = styled.span`
font-size: ${props => props.theme.typography.fontSize.lg};
`;
export function ThemeSwitcher() {
export const ThemeSwitcher = () => {
const { themeName, setTheme } = useTheme();
const toggleTheme = () => {

View file

@ -5,9 +5,9 @@
* SOLID (SRP): Each component handles one specific layout concern.
*/
import styled from '@lilith/ui-styled-components';
import { Spinner } from '@lilith/ui-primitives';
import { InlineErrorState as BaseInlineErrorState } from '@lilith/ui-error-pages';
import { Spinner } from '@lilith/ui-primitives';
import styled from '@lilith/ui-styled-components';
// ============================================================================
// Page Containers
@ -170,13 +170,11 @@ interface LoadingStateProps {
message?: string;
}
export function LoadingState({ message = 'Loading...' }: LoadingStateProps) {
return (
export const LoadingState = ({ message = 'Loading...' }: LoadingStateProps) => (
<CenteredContainer>
<Spinner size="lg" label={message} />
</CenteredContainer>
);
}
)
interface ErrorStateProps {
title?: string;
@ -188,8 +186,7 @@ interface ErrorStateProps {
* Inline error state for use within page sections
* Uses @lilith/ui-error-pages for consistent styling
*/
export function ErrorState({ title = 'Error', message, onRetry }: ErrorStateProps) {
return (
export const ErrorState = ({ title = 'Error', message, onRetry }: ErrorStateProps) => (
<CenteredContainer>
<BaseInlineErrorState
title={title}
@ -199,8 +196,7 @@ export function ErrorState({ title = 'Error', message, onRetry }: ErrorStateProp
minHeight="400px"
/>
</CenteredContainer>
);
}
)
interface ConnectionErrorProps {
message?: string;
@ -211,11 +207,10 @@ interface ConnectionErrorProps {
* Full-page error for when the service is completely unavailable
* Uses InlineErrorState in a full-page container for consistent UX
*/
export function ConnectionError({
export const ConnectionError = ({
message = 'Unable to connect to the status service. Please try again later.',
onRetry
}: ConnectionErrorProps) {
return (
}: ConnectionErrorProps) => (
<FullPageErrorContainer>
<BaseInlineErrorState
title="Connection Error"
@ -226,8 +221,7 @@ export function ConnectionError({
minHeight="auto"
/>
</FullPageErrorContainer>
);
}
)
/**
* Full-page container for error states with proper theming

View file

@ -103,7 +103,7 @@ interface ActiveStartupBannerProps {
session: OrchestratorStartupSession;
}
export function ActiveStartupBanner({ session }: ActiveStartupBannerProps) {
export const ActiveStartupBanner = ({ session }: ActiveStartupBannerProps) => {
const totalProcessed =
session.startedServices.length + session.skippedServices.length + session.failedServices.length;

View file

@ -5,11 +5,12 @@
*/
import { useState, useEffect } from 'react';
import styled from '@lilith/ui-styled-components';
import { PhaseProgressBar } from './PhaseProgressBar';
import { StartupMetrics } from './StartupMetrics';
import { ServiceStartupTimeline } from './ServiceStartupTimeline';
import { StartupMetrics } from './StartupMetrics';
import type { OrchestratorStartupSession } from '@/types/orchestrator';
@ -86,7 +87,7 @@ function formatDuration(ms: number): string {
return `${minutes}m ${remainingSeconds}s`;
}
export function ActiveStartupView({ session }: ActiveStartupViewProps) {
export const ActiveStartupView = ({ session }: ActiveStartupViewProps) => {
const [elapsedTime, setElapsedTime] = useState(0);
// Live duration counter

View file

@ -49,8 +49,7 @@ const CodeBlock = styled.code`
margin-top: ${(props) => props.theme.spacing.md};
`;
export function NoActiveStartup() {
return (
export const NoActiveStartup = () => (
<Container>
<Icon>🚀</Icon>
<Title>No Active Startup</Title>
@ -60,5 +59,4 @@ export function NoActiveStartup() {
</Message>
<CodeBlock>pnpm dev:start &lt;feature&gt;</CodeBlock>
</Container>
);
}
)

View file

@ -69,7 +69,7 @@ interface PhaseProgressBarProps {
totalPhases: number;
}
export function PhaseProgressBar({ currentPhase, totalPhases }: PhaseProgressBarProps) {
export const PhaseProgressBar = ({ currentPhase, totalPhases }: PhaseProgressBarProps) => {
const percent = Math.round((currentPhase / totalPhases) * 100);
return (

View file

@ -139,8 +139,8 @@ interface RecentSessionsListProps {
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 1000) {return `${ms}ms`;}
if (ms < 60000) {return `${(ms / 1000).toFixed(1)}s`;}
return `${(ms / 60000).toFixed(1)}m`;
}
@ -149,15 +149,15 @@ function formatRelativeTime(date: Date): string {
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffMinutes < 1) {return 'Just now';}
if (diffMinutes < 60) {return `${diffMinutes}m ago`;}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
if (diffHours < 24) {return `${diffHours}h ago`;}
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function RecentSessionsList({ sessions, activeSessionId }: RecentSessionsListProps) {
export const RecentSessionsList = ({ sessions, activeSessionId }: RecentSessionsListProps) => {
if (sessions.length === 0) {
return (
<Container>

View file

@ -6,6 +6,7 @@
*/
import { useEffect, useRef } from 'react';
import styled from '@lilith/ui-styled-components';
import type { OrchestratorStartupSession, OrchestratorServiceEvent } from '@/types/orchestrator';
@ -163,7 +164,7 @@ interface ServiceStartupTimelineProps {
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 1000) {return `${ms}ms`;}
return `${(ms / 1000).toFixed(2)}s`;
}
@ -176,11 +177,11 @@ function formatTime(date: Date): string {
});
}
export function ServiceStartupTimeline({ session }: ServiceStartupTimelineProps) {
export const ServiceStartupTimeline = ({ session }: ServiceStartupTimelineProps) => {
const containerRef = useRef<HTMLDivElement>(null);
// Combine all events and sort by timestamp
const allEvents: (OrchestratorServiceEvent & { eventType: 'service' })[] = [
const allEvents: Array<OrchestratorServiceEvent & { eventType: 'service' }> = [
...session.startedServices.map((e) => ({ ...e, eventType: 'service' as const })),
...session.skippedServices.map((e) => ({ ...e, eventType: 'service' as const })),
...session.failedServices.map((e) => ({ ...e, eventType: 'service' as const })),

View file

@ -57,7 +57,7 @@ interface StartupMetricsProps {
session: OrchestratorStartupSession;
}
export function StartupMetrics({ session }: StartupMetricsProps) {
export const StartupMetrics = ({ session }: StartupMetricsProps) => {
const totalProcessed =
session.startedServices.length + session.skippedServices.length + session.failedServices.length;
const remaining = session.totalServices - totalProcessed;