diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts index fd0894da5..865801001 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts @@ -9,6 +9,7 @@ import { import { HttpClient, HttpParams } from '@angular/common/http'; import { RouterLink } from '@angular/router'; import { catchError, map, of, take } from 'rxjs'; +import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component'; import { PlatformContextStore } from '../../core/context/platform-context.store'; import { TopologyLayoutService } from './topology-layout.service'; @@ -38,7 +39,7 @@ const PENDING_STATUSES = new Set([ @Component({ selector: 'app-topology-graph-page', standalone: true, - imports: [RouterLink, TopologyGraphComponent], + imports: [RouterLink, TopologyGraphComponent, PaginationComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -145,39 +146,75 @@ const PENDING_STATUSES = new Set([

No hosts or targets registered.

} - +
-

Releases

-
+

Deployments

+
- @if (filteredReleases().length > 0) { - + @if (pagedReleases().length > 0) { +
- + + + + + + - @for (rel of filteredReleases(); track rel.activityId) { + @for (rel of pagedReleases(); track rel.activityId) { - + + }
ReleaseVersionStatus
+ Release + @if (sortColumn() === 'releaseName') { + {{ sortAsc() ? '\u25B2' : '\u25BC' }} + } + Event + Status + @if (sortColumn() === 'status') { + {{ sortAsc() ? '\u25B2' : '\u25BC' }} + } + + Date + @if (sortColumn() === 'occurredAt') { + {{ sortAsc() ? '\u25B2' : '\u25BC' }} + } +
{{ rel.releaseName }}{{ rel.version || '—' }}{{ rel.eventType }} {{ rel.status }}{{ formatDate(rel.occurredAt) }}
+ @if (filteredReleases().length > deployPageSize) { + + } } @else if (!detailLoading()) { -

No {{ releasesShowDone() ? 'completed' : 'pending' }} releases.

+

No {{ releasesShowDone() ? 'completed' : 'pending' }} deployments.

} @if (detailLoading()) {

Loading...

@@ -458,29 +495,78 @@ const PENDING_STATUSES = new Set([ border-top: 1px solid var(--color-border-primary); } - .toggle-group { - display: flex; + /* Segmented control — matches ctx__segmented from context-chips */ + .seg-control { + display: inline-flex; + align-items: center; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); overflow: hidden; - } - - .toggle-group button { - border: none; background: var(--color-surface-secondary); - color: var(--color-text-secondary); - font-size: 0.65rem; - padding: 0.15rem 0.4rem; + height: 22px; + } + + .seg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 0.4rem; + border: none; + background: transparent; + color: var(--color-text-muted); + font-size: 0.6rem; + font-family: inherit; + font-weight: 500; cursor: pointer; + white-space: nowrap; + transition: background 150ms, color 150ms; } - .toggle-group button + button { - border-left: 1px solid var(--color-border-primary); + .seg-btn:hover:not(.seg-btn--active) { + color: var(--color-text-secondary); + background: var(--color-surface-tertiary); } - .toggle-group .toggle-active { - background: var(--color-brand-primary); - color: var(--color-btn-primary-text, #fff); + .seg-btn--active { + background: var(--color-surface-tertiary); + color: var(--color-text-primary); + font-weight: 600; + } + + .seg-btn:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: -2px; + } + + /* Deployment table */ + .drawer-deploy-table { + font-size: 0.72rem; + } + + .sortable-th { + cursor: pointer; + user-select: none; + } + + .sortable-th:hover { + color: var(--color-text-primary); + } + + .sort-arrow { + font-size: 0.55rem; + margin-left: 0.15rem; + } + + .event-type { + font-size: 0.65rem; + color: var(--color-text-secondary); + } + + .date-cell { + font-size: 0.65rem; + color: var(--color-text-secondary); + white-space: nowrap; } .drawer-link { @@ -544,13 +630,34 @@ export class TopologyGraphPageComponent { readonly detailReleases = signal([]); readonly detailLoading = signal(false); readonly releasesShowDone = signal(false); + readonly sortColumn = signal('occurredAt'); + readonly sortAsc = signal(false); + readonly deployPage = signal(1); + readonly deployPageSize = 5; readonly filteredReleases = computed(() => { const all = this.detailReleases(); const showDone = this.releasesShowDone(); - return all.filter((r) => + const filtered = all.filter((r) => showDone ? !PENDING_STATUSES.has(r.status) : PENDING_STATUSES.has(r.status), ); + + const col = this.sortColumn() as keyof ReleaseActivity; + const asc = this.sortAsc(); + filtered.sort((a, b) => { + const va = String(a[col] ?? ''); + const vb = String(b[col] ?? ''); + const cmp = va.localeCompare(vb); + return asc ? cmp : -cmp; + }); + + return filtered; + }); + + readonly pagedReleases = computed(() => { + const all = this.filteredReleases(); + const start = (this.deployPage() - 1) * this.deployPageSize; + return all.slice(start, start + this.deployPageSize); }); readonly detailTitle = computed(() => { @@ -628,6 +735,35 @@ export class TopologyGraphPageComponent { this.detailHosts.set([]); this.detailReleases.set([]); this.releasesShowDone.set(false); + this.sortColumn.set('occurredAt'); + this.sortAsc.set(false); + this.deployPage.set(1); + } + + toggleSort(column: string): void { + if (this.sortColumn() === column) { + this.sortAsc.update((v) => !v); + } else { + this.sortColumn.set(column); + this.sortAsc.set(column === 'releaseName'); + } + this.deployPage.set(1); + } + + onDeployPageChange(event: PageChangeEvent): void { + this.deployPage.set(event.page); + } + + formatDate(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + const now = Date.now(); + const diffMs = now - d.getTime(); + if (diffMs < 60_000) return 'just now'; + if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`; + if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`; + if (diffMs < 604_800_000) return `${Math.floor(diffMs / 86_400_000)}d ago`; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } getNodeLabel(nodeId: string): string {