Add releases table with pending/done toggle to environment side drawer
When an environment is selected, the side drawer now shows a Releases section with a Pending/Done toggle. Fetches from /api/v2/releases/activity filtered by environment. Release name and version are clickable links to the release detail and version pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,9 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { catchError, of, take } from 'rxjs';
|
import { catchError, map, of, take } from 'rxjs';
|
||||||
|
|
||||||
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';
|
||||||
@@ -17,7 +18,22 @@ import {
|
|||||||
TopologyPositionedNode,
|
TopologyPositionedNode,
|
||||||
TopologyRoutedEdge,
|
TopologyRoutedEdge,
|
||||||
} from './topology-layout.models';
|
} from './topology-layout.models';
|
||||||
import { TopologyTarget, TopologyHost } from './topology.models';
|
import { TopologyTarget, TopologyHost, PlatformListResponse } from './topology.models';
|
||||||
|
|
||||||
|
interface ReleaseActivity {
|
||||||
|
activityId: string;
|
||||||
|
releaseId: string;
|
||||||
|
releaseName: string;
|
||||||
|
version?: string;
|
||||||
|
status: string;
|
||||||
|
eventType: string;
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_STATUSES = new Set([
|
||||||
|
'pending', 'promoting', 'awaiting_approval', 'gates_running',
|
||||||
|
'deploying', 'draft', 'ready',
|
||||||
|
]);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-topology-graph-page',
|
selector: 'app-topology-graph-page',
|
||||||
@@ -128,6 +144,41 @@ import { TopologyTarget, TopologyHost } from './topology.models';
|
|||||||
@if (detailTargets().length === 0 && detailHosts().length === 0 && !detailLoading()) {
|
@if (detailTargets().length === 0 && detailHosts().length === 0 && !detailLoading()) {
|
||||||
<p class="drawer-empty">No hosts or targets registered.</p>
|
<p class="drawer-empty">No hosts or targets registered.</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Releases -->
|
||||||
|
<div class="drawer-section-header">
|
||||||
|
<h3 class="drawer-section">Releases</h3>
|
||||||
|
<div class="toggle-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[class.toggle-active]="!releasesShowDone()"
|
||||||
|
(click)="releasesShowDone.set(false)"
|
||||||
|
>Pending</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[class.toggle-active]="releasesShowDone()"
|
||||||
|
(click)="releasesShowDone.set(true)"
|
||||||
|
>Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (filteredReleases().length > 0) {
|
||||||
|
<table class="drawer-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Release</th><th>Version</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (rel of filteredReleases(); 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><span class="release-status" [class]="'release-status--' + rel.status">{{ rel.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
} @else if (!detailLoading()) {
|
||||||
|
<p class="drawer-empty">No {{ releasesShowDone() ? 'completed' : 'pending' }} releases.</p>
|
||||||
|
}
|
||||||
@if (detailLoading()) {
|
@if (detailLoading()) {
|
||||||
<p class="drawer-loading">Loading...</p>
|
<p class="drawer-loading">Loading...</p>
|
||||||
}
|
}
|
||||||
@@ -398,6 +449,60 @@ import { TopologyTarget, TopologyHost } from './topology.models';
|
|||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section header with toggle */
|
||||||
|
.drawer-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0.3rem;
|
||||||
|
border-top: 1px solid var(--color-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group button + button {
|
||||||
|
border-left: 1px solid var(--color-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group .toggle-active {
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
color: var(--color-btn-primary-text, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-link {
|
||||||
|
color: var(--color-text-link);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-status {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-status--deployed, .release-status--succeeded { color: var(--color-status-success-text); }
|
||||||
|
.release-status--pending, .release-status--promoting, .release-status--awaiting_approval { color: var(--color-status-warning-text); }
|
||||||
|
.release-status--failed, .release-status--cancelled, .release-status--rejected { color: var(--color-status-error-text); }
|
||||||
|
.release-status--draft, .release-status--ready { color: var(--color-text-secondary); }
|
||||||
|
|
||||||
.drawer-actions {
|
.drawer-actions {
|
||||||
padding-top: 0.3rem;
|
padding-top: 0.3rem;
|
||||||
border-top: 1px solid var(--color-border-primary);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
@@ -425,6 +530,7 @@ import { TopologyTarget, TopologyHost } from './topology.models';
|
|||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class TopologyGraphPageComponent {
|
export class TopologyGraphPageComponent {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
private readonly layoutService = inject(TopologyLayoutService);
|
private readonly layoutService = inject(TopologyLayoutService);
|
||||||
readonly context = inject(PlatformContextStore);
|
readonly context = inject(PlatformContextStore);
|
||||||
|
|
||||||
@@ -435,7 +541,17 @@ export class TopologyGraphPageComponent {
|
|||||||
readonly selectedEdge = signal<TopologyRoutedEdge | null>(null);
|
readonly selectedEdge = signal<TopologyRoutedEdge | null>(null);
|
||||||
readonly detailTargets = signal<TopologyTarget[]>([]);
|
readonly detailTargets = signal<TopologyTarget[]>([]);
|
||||||
readonly detailHosts = signal<TopologyHost[]>([]);
|
readonly detailHosts = signal<TopologyHost[]>([]);
|
||||||
|
readonly detailReleases = signal<ReleaseActivity[]>([]);
|
||||||
readonly detailLoading = signal(false);
|
readonly detailLoading = signal(false);
|
||||||
|
readonly releasesShowDone = signal(false);
|
||||||
|
|
||||||
|
readonly filteredReleases = computed(() => {
|
||||||
|
const all = this.detailReleases();
|
||||||
|
const showDone = this.releasesShowDone();
|
||||||
|
return all.filter((r) =>
|
||||||
|
showDone ? !PENDING_STATUSES.has(r.status) : PENDING_STATUSES.has(r.status),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
readonly detailTitle = computed(() => {
|
readonly detailTitle = computed(() => {
|
||||||
const node = this.selectedNode();
|
const node = this.selectedNode();
|
||||||
@@ -488,8 +604,7 @@ export class TopologyGraphPageComponent {
|
|||||||
onNodeSelected(node: TopologyPositionedNode): void {
|
onNodeSelected(node: TopologyPositionedNode): void {
|
||||||
this.selectedNode.set(node);
|
this.selectedNode.set(node);
|
||||||
this.selectedEdge.set(null);
|
this.selectedEdge.set(null);
|
||||||
this.detailTargets.set([]);
|
this.resetDetail();
|
||||||
this.detailHosts.set([]);
|
|
||||||
|
|
||||||
if (node.kind === 'environment' && node.environmentId) {
|
if (node.kind === 'environment' && node.environmentId) {
|
||||||
this.loadEnvironmentDetail(node.environmentId);
|
this.loadEnvironmentDetail(node.environmentId);
|
||||||
@@ -499,15 +614,20 @@ export class TopologyGraphPageComponent {
|
|||||||
onEdgeSelected(edge: TopologyRoutedEdge): void {
|
onEdgeSelected(edge: TopologyRoutedEdge): void {
|
||||||
this.selectedEdge.set(edge);
|
this.selectedEdge.set(edge);
|
||||||
this.selectedNode.set(null);
|
this.selectedNode.set(null);
|
||||||
this.detailTargets.set([]);
|
this.resetDetail();
|
||||||
this.detailHosts.set([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection(): void {
|
clearSelection(): void {
|
||||||
this.selectedNode.set(null);
|
this.selectedNode.set(null);
|
||||||
this.selectedEdge.set(null);
|
this.selectedEdge.set(null);
|
||||||
|
this.resetDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetDetail(): void {
|
||||||
this.detailTargets.set([]);
|
this.detailTargets.set([]);
|
||||||
this.detailHosts.set([]);
|
this.detailHosts.set([]);
|
||||||
|
this.detailReleases.set([]);
|
||||||
|
this.releasesShowDone.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
getNodeLabel(nodeId: string): string {
|
getNodeLabel(nodeId: string): string {
|
||||||
@@ -553,14 +673,28 @@ export class TopologyGraphPageComponent {
|
|||||||
this.detailLoading.set(true);
|
this.detailLoading.set(true);
|
||||||
|
|
||||||
this.layoutService.getTargets(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({
|
this.layoutService.getTargets(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({
|
||||||
next: (targets) => {
|
next: (targets) => this.detailTargets.set(targets),
|
||||||
this.detailTargets.set(targets);
|
|
||||||
this.detailLoading.set(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.layoutService.getHosts(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({
|
this.layoutService.getHosts(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({
|
||||||
next: (hosts) => this.detailHosts.set(hosts),
|
next: (hosts) => this.detailHosts.set(hosts),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const params = new HttpParams()
|
||||||
|
.set('environment', environmentId)
|
||||||
|
.set('limit', '20');
|
||||||
|
this.http
|
||||||
|
.get<PlatformListResponse<ReleaseActivity>>('/api/v2/releases/activity', { params })
|
||||||
|
.pipe(
|
||||||
|
take(1),
|
||||||
|
map((r) => r?.items ?? []),
|
||||||
|
catchError(() => of([])),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (releases) => {
|
||||||
|
this.detailReleases.set(releases);
|
||||||
|
this.detailLoading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user