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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user