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 { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { catchError, map, of, take } from 'rxjs';
|
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 { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||||
import { TopologyLayoutService } from './topology-layout.service';
|
import { TopologyLayoutService } from './topology-layout.service';
|
||||||
@@ -38,7 +39,7 @@ const PENDING_STATUSES = new Set([
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-topology-graph-page',
|
selector: 'app-topology-graph-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, TopologyGraphComponent],
|
imports: [RouterLink, TopologyGraphComponent, PaginationComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<section class="topo-page" [class.topo-page--panel-open]="selectedNode() || selectedEdge()">
|
<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>
|
<p class="drawer-empty">No hosts or targets registered.</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Releases -->
|
<!-- Deployments -->
|
||||||
<div class="drawer-section-header">
|
<div class="drawer-section-header">
|
||||||
<h3 class="drawer-section">Releases</h3>
|
<h3 class="drawer-section">Deployments</h3>
|
||||||
<div class="toggle-group">
|
<div class="seg-control" role="radiogroup" aria-label="Deployment filter">
|
||||||
<button
|
<button
|
||||||
type="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)"
|
(click)="releasesShowDone.set(false)"
|
||||||
>Pending</button>
|
>Pending</button>
|
||||||
<button
|
<button
|
||||||
type="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)"
|
(click)="releasesShowDone.set(true)"
|
||||||
>Done</button>
|
>Done</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (filteredReleases().length > 0) {
|
@if (pagedReleases().length > 0) {
|
||||||
<table class="drawer-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable drawer-deploy-table">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (rel of filteredReleases(); track rel.activityId) {
|
@for (rel of pagedReleases(); track rel.activityId) {
|
||||||
<tr>
|
<tr>
|
||||||
<td><a class="drawer-link" [routerLink]="['/releases/detail', rel.releaseId]">{{ rel.releaseName }}</a></td>
|
<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><span class="release-status" [class]="'release-status--' + rel.status">{{ rel.status }}</span></td>
|
||||||
|
<td class="date-cell">{{ formatDate(rel.occurredAt) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@if (filteredReleases().length > deployPageSize) {
|
||||||
|
<app-pagination
|
||||||
|
[total]="filteredReleases().length"
|
||||||
|
[pageSize]="deployPageSize"
|
||||||
|
[currentPage]="deployPage()"
|
||||||
|
[compact]="true"
|
||||||
|
(pageChange)="onDeployPageChange($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
} @else if (!detailLoading()) {
|
} @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()) {
|
@if (detailLoading()) {
|
||||||
<p class="drawer-loading">Loading...</p>
|
<p class="drawer-loading">Loading...</p>
|
||||||
@@ -458,29 +495,78 @@ const PENDING_STATUSES = new Set([
|
|||||||
border-top: 1px solid var(--color-border-primary);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-group {
|
/* Segmented control — matches ctx__segmented from context-chips */
|
||||||
display: flex;
|
.seg-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-group button {
|
|
||||||
border: none;
|
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
color: var(--color-text-secondary);
|
height: 22px;
|
||||||
font-size: 0.65rem;
|
}
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
|
.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;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 150ms, color 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-group button + button {
|
.seg-btn:hover:not(.seg-btn--active) {
|
||||||
border-left: 1px solid var(--color-border-primary);
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-surface-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-group .toggle-active {
|
.seg-btn--active {
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-surface-tertiary);
|
||||||
color: var(--color-btn-primary-text, #fff);
|
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 {
|
.drawer-link {
|
||||||
@@ -544,13 +630,34 @@ export class TopologyGraphPageComponent {
|
|||||||
readonly detailReleases = signal<ReleaseActivity[]>([]);
|
readonly detailReleases = signal<ReleaseActivity[]>([]);
|
||||||
readonly detailLoading = signal(false);
|
readonly detailLoading = signal(false);
|
||||||
readonly releasesShowDone = 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(() => {
|
readonly filteredReleases = computed(() => {
|
||||||
const all = this.detailReleases();
|
const all = this.detailReleases();
|
||||||
const showDone = this.releasesShowDone();
|
const showDone = this.releasesShowDone();
|
||||||
return all.filter((r) =>
|
const filtered = all.filter((r) =>
|
||||||
showDone ? !PENDING_STATUSES.has(r.status) : PENDING_STATUSES.has(r.status),
|
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(() => {
|
readonly detailTitle = computed(() => {
|
||||||
@@ -628,6 +735,35 @@ export class TopologyGraphPageComponent {
|
|||||||
this.detailHosts.set([]);
|
this.detailHosts.set([]);
|
||||||
this.detailReleases.set([]);
|
this.detailReleases.set([]);
|
||||||
this.releasesShowDone.set(false);
|
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 {
|
getNodeLabel(nodeId: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user