-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathakrites-package-drawer.component.ts
More file actions
343 lines (305 loc) · 13.8 KB
/
Copy pathakrites-package-drawer.component.ts
File metadata and controls
343 lines (305 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
import { Component, computed, DestroyRef, inject, input, model, output, Signal, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { catchError, distinctUntilChanged, finalize, of, switchMap, take } from 'rxjs';
import { DrawerModule } from 'primeng/drawer';
import { MessageService } from 'primeng/api';
import {
AkritesAssignStewardRequest,
AkritesEscalateRequest,
AkritesPackage,
AkritesStatus,
AkritesSteward,
AkritesUpdateStatusRequest,
AkritesUpdatableStatus,
TagSeverity,
} from '@lfx-one/shared/interfaces';
import { AkritesService } from '@shared/services/akrites.service';
import { ButtonComponent } from '@components/button/button.component';
import { EmptyStateComponent } from '@components/empty-state/empty-state.component';
import { TagComponent } from '@components/tag/tag.component';
import { AkritesAssignStewardModalComponent } from '../akrites-assign-steward-modal/akrites-assign-steward-modal.component';
import { AkritesEscalateModalComponent } from '../akrites-escalate-modal/akrites-escalate-modal.component';
import { AkritesStatusModalComponent } from '../akrites-status-modal/akrites-status-modal.component';
import {
formatStatus,
getAdvisoryTagSeverity,
getHealthLabel,
getHealthTagSeverity,
getLifecycleLabel,
getLifecycleTagSeverity,
getStatusTagSeverity,
} from '../../akrites.utils';
type DrawerTab = 'overview' | 'assessment' | 'security' | 'provenance' | 'history';
@Component({
selector: 'lfx-akrites-package-drawer',
imports: [
DrawerModule,
ButtonComponent,
EmptyStateComponent,
TagComponent,
AkritesAssignStewardModalComponent,
AkritesEscalateModalComponent,
AkritesStatusModalComponent,
],
templateUrl: './akrites-package-drawer.component.html',
})
export class AkritesPackageDrawerComponent {
private readonly akritesService = inject(AkritesService);
private readonly messageService = inject(MessageService);
private readonly destroyRef = inject(DestroyRef);
public readonly visible = model(false);
public readonly packageId = input<string | null>(null);
/** Stewardship state from the list row — used until the detail endpoint's stewardship block loads. */
public readonly packageStatus = input<AkritesStatus | null>(null);
/** Emitted after a successful steward admin action so the dashboard can refresh the list/metrics. */
public readonly stewardshipChanged = output<void>();
protected readonly activeTab = signal<DrawerTab>('overview');
protected readonly detailLoading = signal(false);
protected readonly actionLoading = signal(false);
protected readonly assignStewardModalVisible = signal(false);
protected readonly escalateModalVisible = signal(false);
protected readonly statusModalVisible = signal(false);
private readonly reloadTrigger = signal(0);
protected readonly packageData: Signal<AkritesPackage | null> = this.initPackageData();
protected readonly drawerTabs: { key: DrawerTab; label: string }[] = [
{ key: 'overview', label: 'Overview' },
{ key: 'assessment', label: 'Assessment' },
{ key: 'security', label: 'Security' },
{ key: 'provenance', label: 'Provenance' },
{ key: 'history', label: 'History' },
];
// Prefer the loaded detail status (fresh after a mutation + reload) over the list-row input, which can be stale.
protected readonly stewardshipStatus = computed<AkritesStatus>(() => this.packageData()?.status ?? this.packageStatus() ?? 'unassigned');
protected readonly stewardshipId = computed<number | null>(() => this.packageData()?.stewardshipId ?? null);
// Action availability. Open is for not-yet-stewarded packages; status/escalate need an existing stewardship row.
protected readonly canOpenForStewardship = computed(() => this.stewardshipStatus() === 'unassigned');
// Show "Assign steward" for unassigned/open and "Reassign" for assessing/escalated/inactive.
protected readonly canAssignSteward = computed(() => {
const s = this.stewardshipStatus();
return ['unassigned', 'open', 'assessing', 'escalated', 'inactive'].includes(s);
});
protected readonly assignStewardLabel = computed(() => {
const s = this.stewardshipStatus();
return ['assessing', 'escalated', 'inactive'].includes(s) ? 'Reassign' : 'Assign steward';
});
protected readonly canManageStatus = computed(() => this.stewardshipId() !== null);
protected readonly canEscalate = computed(() => {
const status = this.stewardshipStatus();
return this.stewardshipId() !== null && status !== 'escalated' && status !== 'inactive' && status !== 'unassigned';
});
protected readonly canSpotCheck = computed(() => {
const s = this.stewardshipStatus();
return ['assessing', 'active', 'needs_attention', 'blocked'].includes(s) && this.stewardshipId() !== null;
});
protected readonly canResolve = computed(() => this.stewardshipStatus() === 'escalated' && this.stewardshipId() !== null);
protected readonly canResolveBlocker = computed(() => this.stewardshipStatus() === 'blocked' && this.stewardshipId() !== null);
protected readonly canCloseAvailability = computed(() => this.stewardshipStatus() === 'open' && this.stewardshipId() !== null);
protected readonly canReactivate = computed(() => this.stewardshipStatus() === 'inactive' && this.stewardshipId() !== null);
protected readonly formattedStatus = computed(() => formatStatus(this.stewardshipStatus()));
protected readonly statusTagSeverity = computed(() => getStatusTagSeverity(this.stewardshipStatus()));
protected readonly healthLabel = computed(() => {
const score = this.packageData()?.healthScore;
return score != null ? getHealthLabel(score) : '—';
});
protected readonly healthTagSeverity = computed(() => getHealthTagSeverity(this.packageData()?.healthScore ?? null));
protected readonly healthBreakdown = computed(() => {
const pkg = this.packageData();
return [pkg?.healthBreakdown[0] || '—', pkg?.healthBreakdown[1] || '—', pkg?.healthBreakdown[2] || '—'];
});
protected readonly lifecycleLabel = computed(() => getLifecycleLabel(this.packageData()?.lifecycle ?? null));
protected readonly lifecycleTagSeverity = computed(() => getLifecycleTagSeverity(this.packageData()?.lifecycle ?? null));
protected readonly safeRepoUrl = computed(() => this.getSafeRepoUrl(this.packageData()?.repoUrl ?? null));
protected readonly mappingTagSeverity = computed(() => this.getMappingTagSeverity(this.packageData()?.supplyChainMapping ?? null));
protected readonly stewardLabel = computed(() => this.getStewardLabel(this.packageData()?.stewards ?? []));
protected readonly enrichedAdvisories = computed(() =>
(this.packageData()?.advisories ?? []).map((a) => ({ ...a, tagSeverity: getAdvisoryTagSeverity(a.severity) }))
);
protected readonly enrichedHistory = computed(() => (this.packageData()?.history ?? []).map((e) => ({ ...e, dotClass: this.getHistoryDotClass(e.type) })));
protected onTabChange(tab: DrawerTab): void {
this.activeTab.set(tab);
}
protected onClose(): void {
this.visible.set(false);
}
protected getSafeRepoUrl(repoUrl: string | null): string | null {
if (!repoUrl) return null;
try {
const urlString = repoUrl.includes('://') ? repoUrl : 'https://' + repoUrl;
const url = new URL(urlString);
return url.protocol === 'https:' ? url.href : null;
} catch {
return null;
}
}
protected getHistoryDotClass(entryType: string | undefined): string {
const base = 'absolute -left-5 top-1 w-2.5 h-2.5 rounded-full border-2 border-white';
if (entryType === 'danger') return `${base} bg-red-500`;
if (entryType === 'success') return `${base} bg-emerald-500`;
return `${base} bg-blue-500`;
}
protected getHealthBreakdownSlot(pkg: AkritesPackage, index: number): string {
// healthBreakdown is positional (maintainer / security / development) and
// empty when CDP returns no health score at all.
return pkg.healthBreakdown[index] || '—';
}
protected isStale(monthsStale: number | null): boolean {
return monthsStale !== null && monthsStale >= 18;
}
protected getMappingTagSeverity(mapping: AkritesPackage['supplyChainMapping']): TagSeverity {
if (mapping === 'High') return 'success';
if (mapping === 'Medium') return 'warn';
if (mapping === 'Low') return 'danger';
return 'secondary';
}
/** Display label for assigned stewards. Falls back to the Auth0 sub until the roster endpoint provides names. */
protected getStewardLabel(stewards: AkritesSteward[]): string {
if (stewards.length === 0) return '—';
return stewards.map((s) => s.name ?? s.userId).join(', ');
}
protected onSpotCheck(): void {
this.activeTab.set('assessment');
}
protected onQuickStatusUpdate(status: AkritesUpdatableStatus): void {
const id = this.stewardshipId();
if (id === null || this.actionLoading()) return;
this.actionLoading.set(true);
this.akritesService
.updateStewardshipStatus(id, { status })
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.onActionSuccess(`Status updated to ${status}.`),
error: () => this.onActionError(),
});
}
protected openAssignStewardModal(): void {
this.assignStewardModalVisible.set(true);
}
protected openEscalateModal(): void {
this.escalateModalVisible.set(true);
}
protected openStatusModal(): void {
this.statusModalVisible.set(true);
}
protected onOpenForStewardship(): void {
const pkg = this.packageData();
const purl = pkg?.purl ?? this.packageId();
if (!purl || this.actionLoading()) return;
this.actionLoading.set(true);
this.akritesService
.openStewardship(purl)
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => this.onActionSuccess('Package opened for stewardship.'),
error: () => this.onActionError(),
});
}
protected onAssignStewardConfirm(body: AkritesAssignStewardRequest): void {
if (this.actionLoading()) return;
this.actionLoading.set(true);
const existingId = this.stewardshipId();
if (existingId !== null) {
this.akritesService
.assignSteward(existingId, body)
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.assignStewardModalVisible.set(false);
this.onActionSuccess('Steward assigned successfully.');
},
error: () => this.onActionError(),
});
return;
}
// No stewardship row yet — open first, then assign using the returned id.
const purl = this.packageData()?.purl ?? this.packageId();
if (!purl) {
this.onActionError();
return;
}
this.akritesService
.openStewardship(purl)
.pipe(
switchMap((res) => this.akritesService.assignSteward(parseInt(res.stewardship.id, 10), body)),
take(1),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {
this.assignStewardModalVisible.set(false);
this.onActionSuccess('Steward assigned successfully.');
},
error: () => this.onActionError(),
});
}
protected onEscalateConfirm(body: AkritesEscalateRequest): void {
const id = this.stewardshipId();
if (id === null || this.actionLoading()) return;
this.actionLoading.set(true);
this.akritesService
.escalateStewardship(id, body)
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.escalateModalVisible.set(false);
this.onActionSuccess('Package escalated.');
},
error: () => this.onActionError(),
});
}
protected onStatusConfirm(body: AkritesUpdateStatusRequest): void {
const id = this.stewardshipId();
if (id === null || this.actionLoading()) return;
this.actionLoading.set(true);
this.akritesService
.updateStewardshipStatus(id, body)
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.statusModalVisible.set(false);
this.onActionSuccess('Stewardship status updated.');
},
error: () => this.onActionError(),
});
}
private onActionSuccess(detail: string): void {
this.actionLoading.set(false);
this.messageService.add({ severity: 'success', summary: 'Success', detail });
this.reloadTrigger.update((n) => n + 1);
this.stewardshipChanged.emit();
}
private onActionError(): void {
this.actionLoading.set(false);
this.messageService.add({ severity: 'error', summary: 'Action failed', detail: 'Something went wrong. Please try again.' });
}
private initPackageData(): Signal<AkritesPackage | null> {
// Fetch only while the drawer is open for a concrete package; closing the
// drawer maps to null. The reload trigger forces a re-fetch after a mutation
// even though the package id is unchanged.
let lastId: string | null = null;
const source = computed(() => ({ id: this.visible() ? this.packageId() : null, reload: this.reloadTrigger() }));
return toSignal(
toObservable(source).pipe(
distinctUntilChanged((a, b) => a.id === b.id && a.reload === b.reload),
switchMap(({ id }) => {
if (!id) {
lastId = null;
return of(null);
}
// Only reset to the overview tab when opening a different package, not on a post-action reload.
if (id !== lastId) {
this.activeTab.set('overview');
lastId = id;
}
this.detailLoading.set(true);
return this.akritesService.getPackage(id).pipe(
catchError(() => of(null)),
finalize(() => this.detailLoading.set(false))
);
})
),
{ initialValue: null }
);
}
}