feat(orchestrator): ✨ Add real-time session tracking UI (startup banner, progress bar, timeline) & enhanced active startup cards/badges
This commit is contained in:
parent
2c7f0eafe1
commit
721d85b088
15 changed files with 53 additions and 61 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <feature></CodeBlock>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue