Use standard Stella Ops patterns for deployments table in side drawer

- Rename Releases to Deployments
- Replace custom toggle with standard segmented control (ctx__seg-btn
  pattern from context-chips)
- Use stella-table with striped/hoverable classes
- Add sortable columns (Release, Status, Date) with sort arrows
- Add relative date formatting (2h ago, 3d ago, etc.)
- Add pagination via app-pagination component (5 per page)
- Add Event column showing eventType

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-28 23:57:27 +02:00
parent 20feb042b1
commit 55e6ea0672

View File

@@ -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: `
<section class="topo-page" [class.topo-page--panel-open]="selectedNode() || selectedEdge()">
@@ -145,39 +146,75 @@ const PENDING_STATUSES = new Set([
<p class="drawer-empty">No hosts or targets registered.</p>
}
<!-- Releases -->
<!-- Deployments -->
<div class="drawer-section-header">
<h3 class="drawer-section">Releases</h3>
<div class="toggle-group">
<h3 class="drawer-section">Deployments</h3>
<div class="seg-control" role="radiogroup" aria-label="Deployment filter">
<button
type="button"
[class.toggle-active]="!releasesShowDone()"
role="radio"
class="seg-btn"
[class.seg-btn--active]="!releasesShowDone()"
[attr.aria-checked]="!releasesShowDone()"
(click)="releasesShowDone.set(false)"
>Pending</button>
<button
type="button"
[class.toggle-active]="releasesShowDone()"
role="radio"
class="seg-btn"
[class.seg-btn--active]="releasesShowDone()"
[attr.aria-checked]="releasesShowDone()"
(click)="releasesShowDone.set(true)"
>Done</button>
</div>
</div>
@if (filteredReleases().length > 0) {
<table class="drawer-table">
@if (pagedReleases().length > 0) {
<table class="stella-table stella-table--striped stella-table--hoverable drawer-deploy-table">
<thead>
<tr><th>Release</th><th>Version</th><th>Status</th></tr>
<tr>
<th class="sortable-th" (click)="toggleSort('releaseName')">
Release
@if (sortColumn() === 'releaseName') {
<span class="sort-arrow">{{ sortAsc() ? '\u25B2' : '\u25BC' }}</span>
}
</th>
<th>Event</th>
<th class="sortable-th" (click)="toggleSort('status')">
Status
@if (sortColumn() === 'status') {
<span class="sort-arrow">{{ sortAsc() ? '\u25B2' : '\u25BC' }}</span>
}
</th>
<th class="sortable-th" (click)="toggleSort('occurredAt')">
Date
@if (sortColumn() === 'occurredAt') {
<span class="sort-arrow">{{ sortAsc() ? '\u25B2' : '\u25BC' }}</span>
}
</th>
</tr>
</thead>
<tbody>
@for (rel of filteredReleases(); track rel.activityId) {
@for (rel of pagedReleases(); track rel.activityId) {
<tr>
<td><a class="drawer-link" [routerLink]="['/releases/detail', rel.releaseId]">{{ rel.releaseName }}</a></td>
<td><a class="drawer-link" [routerLink]="['/releases/versions', rel.releaseId, 'overview']">{{ rel.version || '—' }}</a></td>
<td class="event-type">{{ rel.eventType }}</td>
<td><span class="release-status" [class]="'release-status--' + rel.status">{{ rel.status }}</span></td>
<td class="date-cell">{{ formatDate(rel.occurredAt) }}</td>
</tr>
}
</tbody>
</table>
@if (filteredReleases().length > deployPageSize) {
<app-pagination
[total]="filteredReleases().length"
[pageSize]="deployPageSize"
[currentPage]="deployPage()"
[compact]="true"
(pageChange)="onDeployPageChange($event)"
/>
}
} @else if (!detailLoading()) {
<p class="drawer-empty">No {{ releasesShowDone() ? 'completed' : 'pending' }} releases.</p>
<p class="drawer-empty">No {{ releasesShowDone() ? 'completed' : 'pending' }} deployments.</p>
}
@if (detailLoading()) {
<p class="drawer-loading">Loading...</p>
@@ -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<ReleaseActivity[]>([]);
readonly detailLoading = signal(false);
readonly releasesShowDone = signal(false);
readonly sortColumn = signal<string>('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 {