diff --git a/frontend/src/app/components/dashboard-summary/dashboard-summary.component.html b/frontend/src/app/components/dashboard-summary/dashboard-summary.component.html new file mode 100644 index 0000000..801eb4e --- /dev/null +++ b/frontend/src/app/components/dashboard-summary/dashboard-summary.component.html @@ -0,0 +1,32 @@ + + + + + + + {{ totalAgents() }} + Total Agents + + + + + @for (item of statusItems(); track item.status) { + + + {{ item.label }} + {{ item.count }} + + } + + + + + + System + {{ healthLabel() }} + + + \ No newline at end of file diff --git a/frontend/src/app/components/dashboard-summary/dashboard-summary.component.scss b/frontend/src/app/components/dashboard-summary/dashboard-summary.component.scss new file mode 100644 index 0000000..3321104 --- /dev/null +++ b/frontend/src/app/components/dashboard-summary/dashboard-summary.component.scss @@ -0,0 +1,218 @@ +// ============================================================================ +// Dashboard Summary Component Styles +// Uses design tokens from styles/_tokens.scss (CUB-21) +// ============================================================================ + +@use '../../../styles/tokens' as tokens; + +// --------------------------------------------------------------------------- +// Host block +// --------------------------------------------------------------------------- +:host { + display: block; +} + +// --------------------------------------------------------------------------- +// Summary container +// --------------------------------------------------------------------------- +.dashboard-summary { + display: flex; + flex-direction: column; + gap: tokens.$spacing-5; + padding: tokens.$spacing-6; + background-color: tokens.$color-surface-medium; + border-radius: tokens.$card-border-radius; + border: 1px solid tokens.$color-surface-lighter; + box-shadow: tokens.$shadow-level-1; +} + +// --------------------------------------------------------------------------- +// Total agents +// --------------------------------------------------------------------------- +.summary-total { + display: flex; + flex-direction: column; + align-items: center; + gap: tokens.$spacing-1; + padding-bottom: tokens.$spacing-4; + border-bottom: 1px solid tokens.$color-surface-lighter; + + &__count { + font-family: tokens.$font-family-brand; + font-size: tokens.$font-size-display-small; + font-weight: tokens.$font-weight-heavy; + line-height: tokens.$line-height-tight; + color: tokens.$color-on-surface; + letter-spacing: tokens.$letter-spacing-tight; + } + + &__label { + font-family: tokens.$font-family-body; + font-size: tokens.$font-size-label-large; + font-weight: tokens.$font-weight-medium; + color: tokens.$color-on-surface-variant; + text-transform: uppercase; + letter-spacing: tokens.$letter-spacing-wide; + } +} + +// --------------------------------------------------------------------------- +// Status breakdown +// --------------------------------------------------------------------------- +.summary-breakdown { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: tokens.$spacing-3; +} + +// --------------------------------------------------------------------------- +// Individual status item +// --------------------------------------------------------------------------- +.status-item { + display: flex; + align-items: center; + gap: tokens.$spacing-2; + padding: tokens.$spacing-3 tokens.$spacing-4; + border-radius: tokens.$radius-md; + background-color: tokens.$color-surface-dark; + border: 1px solid tokens.$color-surface-lighter; + transition: background-color tokens.$duration-short tokens.$easing-standard; + + &__dot { + width: tokens.$status-dot-size; + height: tokens.$status-dot-size; + border-radius: tokens.$radius-full; + flex-shrink: 0; + } + + &__label { + flex: 1; + font-family: tokens.$font-family-body; + font-size: tokens.$font-size-body-medium; + font-weight: tokens.$font-weight-medium; + color: tokens.$color-on-surface-variant; + } + + &__count { + font-family: tokens.$font-family-mono; + font-size: tokens.$font-size-body-large; + font-weight: tokens.$font-weight-bold; + color: tokens.$color-on-surface; + } + + // Status-specific colors using the token map + &--active { + .status-item__dot { + background-color: tokens.$status-active; + animation: pulse-active tokens.$duration-standard tokens.$easing-standard infinite; + } + .status-item__count { color: tokens.$status-active; } + } + + &--idle { + .status-item__dot { + background-color: tokens.$status-idle; + } + .status-item__count { color: tokens.$status-idle; } + } + + &--thinking { + .status-item__dot { + background-color: tokens.$status-thinking; + animation: pulse-thinking 3s tokens.$easing-standard infinite; + } + .status-item__count { color: tokens.$status-thinking; } + } + + &--error { + .status-item__dot { + background-color: tokens.$status-error; + animation: pulse-error tokens.$duration-fast tokens.$easing-standard infinite; + } + .status-item__count { color: tokens.$status-error; } + } +} + +// --------------------------------------------------------------------------- +// System health indicator +// --------------------------------------------------------------------------- +.health-indicator { + display: flex; + align-items: center; + gap: tokens.$spacing-2; + padding: tokens.$spacing-3 tokens.$spacing-4; + border-radius: tokens.$radius-md; + border: 1px solid tokens.$color-surface-lighter; + + &__dot { + width: tokens.$status-dot-size; + height: tokens.$status-dot-size; + border-radius: tokens.$radius-full; + flex-shrink: 0; + } + + &__label { + font-family: tokens.$font-family-body; + font-size: tokens.$font-size-body-medium; + font-weight: tokens.$font-weight-medium; + color: tokens.$color-on-surface-variant; + } + + &__value { + margin-left: auto; + font-family: tokens.$font-family-mono; + font-size: tokens.$font-size-body-medium; + font-weight: tokens.$font-weight-bold; + letter-spacing: tokens.$letter-spacing-mono; + } + + &--healthy { + .health-indicator__dot { + background-color: tokens.$status-active; + animation: pulse-active tokens.$duration-standard tokens.$easing-standard infinite; + } + .health-indicator__value { color: tokens.$status-active; } + } + + &--degraded { + .health-indicator__dot { + background-color: tokens.$status-error; + animation: pulse-error tokens.$duration-fast tokens.$easing-standard infinite; + } + .health-indicator__value { color: tokens.$status-error; } + } + + &--unknown { + .health-indicator__dot { + background-color: tokens.$status-offline; + } + .health-indicator__value { color: tokens.$status-offline; } + } +} + +// --------------------------------------------------------------------------- +// Pulse animations (from the design system) +// --------------------------------------------------------------------------- +@keyframes pulse-active { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +@keyframes pulse-thinking { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.9); } +} + +@keyframes pulse-error { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(0.8); } +} + +// --------------------------------------------------------------------------- +// Responsive: stack on compact screens +// --------------------------------------------------------------------------- +@media (max-width: tokens.$breakpoint-compact) { + .summary-breakdown { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/dashboard-summary/dashboard-summary.component.ts b/frontend/src/app/components/dashboard-summary/dashboard-summary.component.ts new file mode 100644 index 0000000..a22bcff --- /dev/null +++ b/frontend/src/app/components/dashboard-summary/dashboard-summary.component.ts @@ -0,0 +1,91 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { AgentStatusService } from '../../services/agent-status.service'; +import { AgentStatus } from '../../models/agent.model'; + +/** + * Dashboard Summary Component. + * Displays total active agents, status breakdown (Active, Idle, Thinking, Error), + * and a system health indicator. + * + * Binds to AgentStatusService.agents signal — no hardcoded values. + * Uses design tokens from CUB-21 (styles/_tokens.scss, app/design/tokens.ts). + */ +@Component({ + selector: 'app-dashboard-summary', + standalone: true, + imports: [], + templateUrl: './dashboard-summary.component.html', + styleUrl: './dashboard-summary.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardSummaryComponent { + private readonly agentStatusService = inject(AgentStatusService); + + /** All agents from the reactive signal */ + readonly agents = this.agentStatusService.agents; + + /** Total number of agents */ + readonly totalAgents = computed(() => this.agents().length); + + /** Count of agents per status */ + readonly statusCounts = computed(() => { + const agents = this.agents(); + const counts: Record = { + active: 0, + idle: 0, + thinking: 0, + error: 0, + offline: 0, + }; + for (const agent of agents) { + counts[agent.status]++; + } + return counts; + }); + + /** Status items for template iteration */ + readonly statusItems = computed(() => { + const counts = this.statusCounts(); + const total = this.totalAgents(); + return [ + { status: 'active' as AgentStatus, count: counts.active, label: 'Active' }, + { status: 'idle' as AgentStatus, count: counts.idle, label: 'Idle' }, + { status: 'thinking' as AgentStatus, count: counts.thinking, label: 'Thinking' }, + { status: 'error' as AgentStatus, count: counts.error, label: 'Error' }, + ] as const; + }); + + /** + * System health indicator: + * - 'healthy' → no errors, at least one active agent + * - 'degraded' → has errors OR no active agents but some agents exist + * - 'unknown' → no agents registered + */ + readonly systemHealth = computed((): 'healthy' | 'degraded' | 'unknown' => { + const agents = this.agents(); + if (agents.length === 0) return 'unknown'; + const counts = this.statusCounts(); + if (counts.error > 0) return 'degraded'; + if (counts.active > 0) return 'healthy'; + return 'degraded'; + }); + + readonly healthLabel = computed(() => { + const health = this.systemHealth(); + switch (health) { + case 'healthy': return 'Healthy'; + case 'degraded': return 'Degraded'; + case 'unknown': return 'Unknown'; + } + }); + + /** Get the CSS class for a given status */ + statusClass(status: AgentStatus): string { + return `status-item--${status}`; + } + + /** Get the CSS class for system health */ + healthClass(): string { + return `health-indicator--${this.systemHealth()}`; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/dashboard-summary/index.ts b/frontend/src/app/components/dashboard-summary/index.ts new file mode 100644 index 0000000..46bb803 --- /dev/null +++ b/frontend/src/app/components/dashboard-summary/index.ts @@ -0,0 +1 @@ +export { DashboardSummaryComponent } from './dashboard-summary.component'; \ No newline at end of file diff --git a/frontend/src/app/components/index.ts b/frontend/src/app/components/index.ts index 84852cc..f030ee1 100644 --- a/frontend/src/app/components/index.ts +++ b/frontend/src/app/components/index.ts @@ -2,4 +2,5 @@ export * from './quick-jump-button/quick-jump-button.component'; export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component'; export { QuickJumpDrawerComponent } from './quick-jump-drawer/index'; export { AgentSessionDrawerComponent } from './agent-session-drawer/index'; -export type { SessionLogLine, SessionMessage } from './agent-session-drawer/index'; \ No newline at end of file +export type { SessionLogLine, SessionMessage } from './agent-session-drawer/index'; +export { DashboardSummaryComponent } from './dashboard-summary/index'; \ No newline at end of file