Skip to content

Commit 6066f30

Browse files
Merge pull request #5462 from nabramovitz/feature/route-services-ui
Route services UI (#4302 frontend). rebase does not work
2 parents 5fa4985 + 0711564 commit 6066f30

5 files changed

Lines changed: 289 additions & 1 deletion

File tree

src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-routes/cloud-foundry-routes-signal.component.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CommonModule } from '@angular/common';
22
import { Component, ChangeDetectionStrategy, Signal, WritableSignal, computed, inject, signal } from '@angular/core';
33
import { toSignal } from '@angular/core/rxjs-interop';
4-
import { RouterModule } from '@angular/router';
4+
import { Router, RouterModule } from '@angular/router';
55
import { map } from 'rxjs/operators';
66

77
import {
@@ -50,6 +50,7 @@ import { extractHttpErrorMessage } from '../../../../services/extract-error-mess
5050
export class CloudFoundryRoutesSignalComponent {
5151
cfEndpointService = inject(CloudFoundryEndpointService);
5252
private routesConfig = inject(CfRoutesSignalConfigService);
53+
private router = inject(Router);
5354
private userFavoriteManager = inject(UserFavoriteManager);
5455
private confirmDialog = inject(ConfirmationDialogService);
5556
private snackBar = inject(TailwindSnackBarService);
@@ -408,6 +409,15 @@ export class CloudFoundryRoutesSignalComponent {
408409
});
409410
},
410411
},
412+
{
413+
label: 'Route Service', icon: 'lan',
414+
invoke: () => {
415+
void this.router.navigate(
416+
['/services', 'route-service', route.cnsiGuid, route.guid],
417+
{ queryParams: { space: route.spaceGuid, url: route.url } },
418+
);
419+
},
420+
},
411421
{
412422
label: 'Delete', icon: 'delete', danger: true,
413423
invoke: () => {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<app-page-header [breadcrumbs]="breadcrumbs">
2+
{{ title() }}
3+
</app-page-header>
4+
5+
<div class="px-6 py-4 space-y-4">
6+
@if (errorMessage(); as err) {
7+
<div class="rounded border border-danger-strong bg-danger-subtle px-3 py-2 text-danger-strong" role="alert">
8+
{{ err }}
9+
</div>
10+
}
11+
12+
@if (loading()) {
13+
<p class="text-content-muted">Loading route service…</p>
14+
} @else if (showStepper()) {
15+
<!-- Bind (or rebind) — pick a managed service instance in the route's space. -->
16+
<app-steppers [cancel]="'/cloud-foundry/' + cfGuid + '/routes'">
17+
<app-step title="Bind Service" [signalHandle]="bindStepHandle"
18+
[finishButtonText]="busy() ? 'Binding…' : 'Bind'">
19+
@if (serviceInstances().length === 0) {
20+
<p class="text-content-muted">No managed service instances in this space to bind.</p>
21+
} @else {
22+
<p class="text-content-muted mb-2">Select a service instance to bind to this route:</p>
23+
<div class="flex flex-col gap-1">
24+
@for (si of serviceInstances(); track si.guid) {
25+
<label class="flex items-center gap-2 cursor-pointer">
26+
<input type="radio" name="si" [value]="si.guid"
27+
[checked]="selectedSiGuid() === si.guid"
28+
(change)="selectedSiGuid.set(si.guid)" />
29+
<span class="text-content-text">{{ si.name }}</span>
30+
<span class="text-sm text-content-muted">{{ si.servicePlan?.serviceOffering?.name }}</span>
31+
</label>
32+
}
33+
</div>
34+
}
35+
</app-step>
36+
</app-steppers>
37+
} @else if (binding(); as b) {
38+
<!-- Bound — show the route service with Rebind / Unbind operations. -->
39+
<section class="border border-content-border rounded-md p-4 space-y-3">
40+
<div class="flex items-center justify-between">
41+
<h2 class="font-medium text-content-text">Bound service</h2>
42+
<div class="flex items-center gap-4">
43+
<button type="button" class="text-link hover:underline disabled:opacity-50"
44+
[disabled]="busy()" (click)="startRebind()">Rebind</button>
45+
<button type="button" class="text-danger-strong hover:underline disabled:opacity-50"
46+
[disabled]="busy()" (click)="unbind()">{{ busy() ? 'Unbinding…' : 'Unbind' }}</button>
47+
</div>
48+
</div>
49+
<table class="text-left text-sm">
50+
<tbody>
51+
<tr>
52+
<td class="py-1 pr-8 align-top text-content-muted">Service instance</td>
53+
<td class="py-1 align-top font-mono">{{ boundInstanceName() }}</td>
54+
</tr>
55+
@if (b.routeServiceUrl) {
56+
<tr>
57+
<td class="py-1 pr-8 align-top text-content-muted">Route service URL</td>
58+
<td class="py-1 align-top font-mono break-all">{{ b.routeServiceUrl }}</td>
59+
</tr>
60+
}
61+
<tr>
62+
<td class="py-1 pr-8 align-top text-content-muted">Last operation</td>
63+
<td class="py-1 align-top">{{ b.lastOperationState || '—' }}</td>
64+
</tr>
65+
</tbody>
66+
</table>
67+
</section>
68+
}
69+
</div>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { ChangeDetectionStrategy, Component, Signal, computed, inject, signal } from '@angular/core';
3+
import { ActivatedRoute, Router } from '@angular/router';
4+
5+
import {
6+
IHeaderBreadcrumb,
7+
PageHeaderComponent,
8+
SignalStepHandle,
9+
StepComponent,
10+
SteppersComponent,
11+
} from '@stratosui/core';
12+
import { writeWithJob } from '../../../services/async-jobs/write-with-job';
13+
import { StratosJobError } from '../../../services/async-jobs/async-job.types';
14+
import {
15+
RouteServiceBindingView,
16+
ServiceCatalogDataService,
17+
SignalSource,
18+
} from '../../../services/endpoint-data/service-catalog-data.service';
19+
import { StServiceInstance } from '../../../services/endpoint-data/stratos-types';
20+
21+
// RouteServiceComponent — per-route "route service" management, reached via the
22+
// /services/route-service/:endpointId/:routeGuid route (Route Service row action
23+
// on the cf-routes lists). A route binds to 0-or-1 service instance (a route
24+
// service). When unbound (or rebinding) it shows a one-step stepper to pick a
25+
// managed service instance in the route's space and bind it; when bound it shows
26+
// the binding with Unbind / Rebind. Bind/unbind ride the writeWithJob contract.
27+
@Component({
28+
selector: 'app-route-service',
29+
templateUrl: './route-service.component.html',
30+
standalone: true,
31+
changeDetection: ChangeDetectionStrategy.OnPush,
32+
imports: [PageHeaderComponent, SteppersComponent, StepComponent],
33+
})
34+
export class RouteServiceComponent {
35+
private http = inject(HttpClient);
36+
private catalog = inject(ServiceCatalogDataService);
37+
private router = inject(Router);
38+
39+
readonly cfGuid: string;
40+
readonly routeGuid: string;
41+
readonly spaceGuid: string;
42+
readonly routeUrl: string;
43+
44+
readonly title: Signal<string> = computed(() =>
45+
this.routeUrl ? `Route service for '${this.routeUrl}'` : 'Route service');
46+
readonly breadcrumbs: IHeaderBreadcrumb[];
47+
48+
// Current binding (0-or-1), reloadable by swapping the source signal.
49+
private bindingSource = signal<SignalSource<RouteServiceBindingView | null>>(
50+
{ value: signal<RouteServiceBindingView | null>(null).asReadonly(), isLoading: signal(false).asReadonly(), error: signal(null).asReadonly() },
51+
);
52+
readonly binding: Signal<RouteServiceBindingView | null> = computed(() => this.bindingSource().value());
53+
readonly loading: Signal<boolean> = computed(() => this.bindingSource().isLoading());
54+
55+
// Managed service instances in the route's space — the bind picker, and the
56+
// source for resolving the bound instance's display name.
57+
private siSource: SignalSource<StServiceInstance[]>;
58+
readonly serviceInstances: Signal<StServiceInstance[]> = computed(() =>
59+
this.siSource.value().filter(si => si.type !== 'user-provided'));
60+
boundInstanceName = computed(() => {
61+
const guid = this.binding()?.serviceInstanceGuid;
62+
if (!guid) return '';
63+
return this.siSource.value().find(si => si.guid === guid)?.name ?? guid;
64+
});
65+
66+
readonly selectedSiGuid = signal<string | null>(null);
67+
readonly rebinding = signal(false);
68+
readonly busy = signal(false);
69+
readonly errorMessage = signal<string | null>(null);
70+
71+
// Show the bind stepper when there is no binding, or the user chose Rebind.
72+
readonly showStepper = computed(() => !this.loading() && (!this.binding() || this.rebinding()));
73+
74+
readonly bindStepHandle: SignalStepHandle = {
75+
valid: computed(() => !!this.selectedSiGuid() && !this.busy()),
76+
submit: () => this.bind(),
77+
};
78+
79+
constructor() {
80+
const route = inject(ActivatedRoute);
81+
this.cfGuid = route.snapshot.params.endpointId;
82+
this.routeGuid = route.snapshot.params.routeGuid;
83+
this.spaceGuid = route.snapshot.queryParamMap.get('space') ?? '';
84+
this.routeUrl = route.snapshot.queryParamMap.get('url') ?? '';
85+
this.breadcrumbs = [{ breadcrumbs: [{ value: 'Routes', routerLink: `/cloud-foundry/${this.cfGuid}/routes` }] }];
86+
this.siSource = this.catalog.serviceInstancesInSpace(this.cfGuid, this.spaceGuid);
87+
this.reload();
88+
}
89+
90+
reload(): void {
91+
this.bindingSource.set(this.catalog.routeServiceBinding(this.cfGuid, this.routeGuid));
92+
}
93+
94+
startRebind(): void {
95+
this.selectedSiGuid.set(null);
96+
this.errorMessage.set(null);
97+
this.rebinding.set(true);
98+
}
99+
100+
private async bind(): Promise<void> {
101+
const siGuid = this.selectedSiGuid();
102+
if (!siGuid || this.busy()) return;
103+
this.busy.set(true);
104+
this.errorMessage.set(null);
105+
// Rebind: drop the existing binding first (route-service bindings are
106+
// immutable except metadata, so "edit" = unbind + rebind).
107+
const existing = this.binding();
108+
try {
109+
if (existing) {
110+
await writeWithJob(
111+
this.http,
112+
this.http.delete(`/pp/v1/cf/service_route_bindings/${this.cfGuid}/${existing.guid}`, { observe: 'response' as const }),
113+
);
114+
}
115+
const body = {
116+
relationships: {
117+
route: { data: { guid: this.routeGuid } },
118+
service_instance: { data: { guid: siGuid } },
119+
},
120+
};
121+
await writeWithJob(
122+
this.http,
123+
this.http.post(`/pp/v1/cf/service_route_bindings/${this.cfGuid}`, body, { observe: 'response' as const }),
124+
);
125+
this.rebinding.set(false);
126+
this.selectedSiGuid.set(null);
127+
this.reload();
128+
} catch (err: unknown) {
129+
this.errorMessage.set(`Failed to bind service: ${this.messageOf(err)}`);
130+
throw err instanceof Error ? err : new Error(this.messageOf(err));
131+
} finally {
132+
this.busy.set(false);
133+
}
134+
}
135+
136+
async unbind(): Promise<void> {
137+
const existing = this.binding();
138+
if (!existing || this.busy()) return;
139+
this.busy.set(true);
140+
this.errorMessage.set(null);
141+
try {
142+
await writeWithJob(
143+
this.http,
144+
this.http.delete(`/pp/v1/cf/service_route_bindings/${this.cfGuid}/${existing.guid}`, { observe: 'response' as const }),
145+
);
146+
this.reload();
147+
} catch (err: unknown) {
148+
this.errorMessage.set(`Failed to unbind service: ${this.messageOf(err)}`);
149+
} finally {
150+
this.busy.set(false);
151+
}
152+
}
153+
154+
private messageOf(err: unknown): string {
155+
if (err instanceof StratosJobError) return err.message;
156+
if (err instanceof Error) return err.message;
157+
return 'unknown error';
158+
}
159+
}

src/frontend/packages/cloud-foundry/src/features/services/services.routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AddServiceInstanceComponent,
88
} from '../../shared/components/add-service-instance/add-service-instance/add-service-instance.component';
99
import { DetachServiceInstanceComponent } from './detach-service-instance/detach-service-instance.component';
10+
import { RouteServiceComponent } from './route-service/route-service.component';
1011
import { ServiceKeysComponent } from './service-keys/service-keys.component';
1112
import { ServicesWallComponent } from './services-wall/services-wall.component';
1213

@@ -34,5 +35,9 @@ export const SERVICES_ROUTES: Routes = [
3435
{
3536
path: ':type/:endpointId/:serviceInstanceId/keys',
3637
component: ServiceKeysComponent
38+
},
39+
{
40+
path: 'route-service/:endpointId/:routeGuid',
41+
component: RouteServiceComponent
3742
}
3843
];

src/frontend/packages/cloud-foundry/src/services/endpoint-data/service-catalog-data.service.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,35 @@ interface PagedResp<T> {
1919
pagination?: { totalResults?: number };
2020
}
2121

22+
// A route's route-service binding (a route binds to 0-or-1 service instance).
23+
// Mapped from the raw v3 service_route_binding resource.
24+
export interface RouteServiceBindingView {
25+
guid: string;
26+
serviceInstanceGuid: string;
27+
routeServiceUrl?: string;
28+
lastOperationState?: string;
29+
}
30+
31+
interface RawRouteServiceBinding {
32+
guid: string;
33+
route_service_url?: string;
34+
last_operation?: { state?: string };
35+
relationships?: { service_instance?: { data?: { guid?: string } } };
36+
}
37+
38+
interface RawRouteServiceBindingsResponse {
39+
resources?: RawRouteServiceBinding[];
40+
}
41+
42+
function toRouteServiceBindingView(raw: RawRouteServiceBinding): RouteServiceBindingView {
43+
return {
44+
guid: raw.guid,
45+
serviceInstanceGuid: raw.relationships?.service_instance?.data?.guid ?? '',
46+
routeServiceUrl: raw.route_service_url,
47+
lastOperationState: raw.last_operation?.state,
48+
};
49+
}
50+
2251
// Service keys come back from GH#4301's native handler as raw v3
2352
// service_credential_binding resources (type=key). We only render a few
2453
// fields, so map to this view model rather than the full St shape.
@@ -212,6 +241,22 @@ export class ServiceCatalogDataService {
212241
);
213242
}
214243

244+
// The route-service binding for a single route (0-or-1). Drives the Route
245+
// Service page; reloaded after bind/unbind by re-invoking this.
246+
routeServiceBinding(cnsiGuid: string, routeGuid: string): SignalSource<RouteServiceBindingView | null> {
247+
const params = new HttpParams().set('route_guids', routeGuid);
248+
return this.signalize(
249+
this.http.get<RawRouteServiceBindingsResponse>(
250+
`/pp/v1/cf/service_route_bindings/${cnsiGuid}`,
251+
{ params },
252+
).pipe(map(resp => {
253+
const first = resp?.resources?.[0];
254+
return first ? toRouteServiceBindingView(first) : null;
255+
})),
256+
null,
257+
);
258+
}
259+
215260
// Returns the total count of service instances under a cf, optionally
216261
// narrowed by org or space. Backend ?return=counts emits a flat envelope
217262
// with totalResults populated and resources empty.

0 commit comments

Comments
 (0)