@chisquare-tech/origine-ngx-apps-services 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpClient } from '@angular/common/http';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
|
-
import { inject, Injectable, signal, computed, effect, untracked, DestroyRef, ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
|
3
|
+
import { inject, Injectable, signal, computed, effect, untracked, DestroyRef, ChangeDetectionStrategy, Component, input, output, Directive } from '@angular/core';
|
|
4
4
|
import { ApiService } from '@thescaffold/ngx-core';
|
|
5
5
|
import { map, forkJoin, of } from 'rxjs';
|
|
6
6
|
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
|
|
@@ -66,6 +66,10 @@ class ServicesApi {
|
|
|
66
66
|
removeUser(project, id, name) {
|
|
67
67
|
return this.api.delete(this.i(project, id, 'user/' + enc(name)));
|
|
68
68
|
}
|
|
69
|
+
/** A user's scheduled rotation: every 30 to 365 days, or 0 for none (V0-22). */
|
|
70
|
+
setRotation(project, id, user, rotateEveryDays) {
|
|
71
|
+
return this.api.patch(this.i(project, id, 'user/' + enc(user)), { rotateEveryDays }).pipe(map((r) => data(r)));
|
|
72
|
+
}
|
|
69
73
|
rotate(project, id, user = '', graceMinutes) {
|
|
70
74
|
return this.api.post(this.i(project, id, 'rotate'), { user, graceMinutes }).pipe(map((r) => data(r)));
|
|
71
75
|
}
|
|
@@ -82,6 +86,19 @@ class ServicesApi {
|
|
|
82
86
|
management(project, id) {
|
|
83
87
|
return this.api.post(this.i(project, id, 'management'), {}).pipe(map((r) => data(r)));
|
|
84
88
|
}
|
|
89
|
+
/** A major version upgrade (V8-01): in place or into a new instance, now or in the maintenance window. */
|
|
90
|
+
upgrade(project, id, body) {
|
|
91
|
+
return this.api.post(this.i(project, id, 'upgrade'), body).pipe(map((r) => data(r)));
|
|
92
|
+
}
|
|
93
|
+
cancelUpgrade(project, id) {
|
|
94
|
+
return this.api.delete(this.i(project, id, 'upgrade')).pipe(map((r) => data(r)));
|
|
95
|
+
}
|
|
96
|
+
addExtension(project, id, database, name) {
|
|
97
|
+
return this.api.post(this.i(project, id, 'database/' + enc(database) + '/extension'), { name }).pipe(map((r) => data(r)));
|
|
98
|
+
}
|
|
99
|
+
removeExtension(project, id, database, name) {
|
|
100
|
+
return this.api.delete(this.i(project, id, 'database/' + enc(database) + '/extension/' + enc(name))).pipe(map((r) => data(r)));
|
|
101
|
+
}
|
|
85
102
|
restore(project, id, body) {
|
|
86
103
|
return this.api.post(this.i(project, id, 'restore'), body).pipe(map((r) => data(r)));
|
|
87
104
|
}
|
|
@@ -308,7 +325,8 @@ class ServicesListPage {
|
|
|
308
325
|
this.backups = backupsLine;
|
|
309
326
|
this.price = money;
|
|
310
327
|
this.rows = computed(() => this.list().items.filter((s) => !s.deletedAt || s.state === 'Deleting'), ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
|
|
311
|
-
|
|
328
|
+
/** services.instance.create in some environment (V4-01). */
|
|
329
|
+
this.canManage = computed(() => !!this.host.project() && (this.host.can('services.instance.create', 'unprotected') || this.host.can('services.instance.create', 'protected')), ...(ngDevMode ? [{ debugName: "canManage" }] : /* istanbul ignore next */ []));
|
|
312
330
|
this.quotaLine = computed(() => {
|
|
313
331
|
const q = this.list().quota;
|
|
314
332
|
if (!q)
|
|
@@ -380,8 +398,8 @@ class ServicesListPage {
|
|
|
380
398
|
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
381
399
|
@if (created(); as c) {<div class="o-note" [class.o-grn]="c.running" role="status" data-testid="svc-created">{{ c.text }}</div>}
|
|
382
400
|
<div class="o-card" style="overflow-x:auto">
|
|
383
|
-
<div class="o-tbl" style="min-width:
|
|
384
|
-
<div class="o-thead"><div class="o-tr"><div class="o-th">Name</div><div class="o-th">Engine</div><div class="o-th">Plan</div><div class="o-th">Environment</div><div class="o-th">State</div><div class="o-th">Attached to</div><div class="o-th">Backups</div><div class="o-th o-num">Monthly</div></div></div>
|
|
401
|
+
<div class="o-tbl" style="min-width:960px">
|
|
402
|
+
<div class="o-thead"><div class="o-tr"><div class="o-th">Name</div><div class="o-th">Engine</div><div class="o-th">Plan</div><div class="o-th">Environment</div><div class="o-th">Region</div><div class="o-th">State</div><div class="o-th">Attached to</div><div class="o-th">Backups</div><div class="o-th o-num">Monthly</div></div></div>
|
|
385
403
|
<div class="o-tbody">
|
|
386
404
|
@for (v of rows(); track v.id) {
|
|
387
405
|
<div class="o-tr o-click" (click)="open(v)" (keydown.enter)="open(v)" tabindex="0" role="link" [attr.data-testid]="'svc-row-' + v.name">
|
|
@@ -389,6 +407,7 @@ class ServicesListPage {
|
|
|
389
407
|
<div class="o-td"><span class="o-flex"><span class="o-sq" [style.background]="colour(v.engine)"></span>{{ v.engineName }} {{ v.version }}</span></div>
|
|
390
408
|
<div class="o-td o-mut o-sm">{{ v.plan }} · {{ v.planLabel.split(' · ').slice(0, 2).join(' · ') }} · {{ v.diskGb }} GB</div>
|
|
391
409
|
<div class="o-td o-mut">{{ v.environment || 'shared' }}</div>
|
|
410
|
+
<div class="o-td o-mut o-sm" [attr.data-testid]="'svc-region-' + v.name">{{ v.regionName || v.region || '—' }}</div>
|
|
392
411
|
<div class="o-td"><span [class]="chip(v.state)" [attr.title]="v.message || null">{{ v.state }}</span></div>
|
|
393
412
|
<div class="o-td o-sm">{{ v.attached.length ? v.attached.join(', ') : '—' }}</div>
|
|
394
413
|
<div class="o-td o-faint o-sm">{{ backups(v) }}</div>
|
|
@@ -430,8 +449,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
430
449
|
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
431
450
|
@if (created(); as c) {<div class="o-note" [class.o-grn]="c.running" role="status" data-testid="svc-created">{{ c.text }}</div>}
|
|
432
451
|
<div class="o-card" style="overflow-x:auto">
|
|
433
|
-
<div class="o-tbl" style="min-width:
|
|
434
|
-
<div class="o-thead"><div class="o-tr"><div class="o-th">Name</div><div class="o-th">Engine</div><div class="o-th">Plan</div><div class="o-th">Environment</div><div class="o-th">State</div><div class="o-th">Attached to</div><div class="o-th">Backups</div><div class="o-th o-num">Monthly</div></div></div>
|
|
452
|
+
<div class="o-tbl" style="min-width:960px">
|
|
453
|
+
<div class="o-thead"><div class="o-tr"><div class="o-th">Name</div><div class="o-th">Engine</div><div class="o-th">Plan</div><div class="o-th">Environment</div><div class="o-th">Region</div><div class="o-th">State</div><div class="o-th">Attached to</div><div class="o-th">Backups</div><div class="o-th o-num">Monthly</div></div></div>
|
|
435
454
|
<div class="o-tbody">
|
|
436
455
|
@for (v of rows(); track v.id) {
|
|
437
456
|
<div class="o-tr o-click" (click)="open(v)" (keydown.enter)="open(v)" tabindex="0" role="link" [attr.data-testid]="'svc-row-' + v.name">
|
|
@@ -439,6 +458,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
439
458
|
<div class="o-td"><span class="o-flex"><span class="o-sq" [style.background]="colour(v.engine)"></span>{{ v.engineName }} {{ v.version }}</span></div>
|
|
440
459
|
<div class="o-td o-mut o-sm">{{ v.plan }} · {{ v.planLabel.split(' · ').slice(0, 2).join(' · ') }} · {{ v.diskGb }} GB</div>
|
|
441
460
|
<div class="o-td o-mut">{{ v.environment || 'shared' }}</div>
|
|
461
|
+
<div class="o-td o-mut o-sm" [attr.data-testid]="'svc-region-' + v.name">{{ v.regionName || v.region || '—' }}</div>
|
|
442
462
|
<div class="o-td"><span [class]="chip(v.state)" [attr.title]="v.message || null">{{ v.state }}</span></div>
|
|
443
463
|
<div class="o-td o-sm">{{ v.attached.length ? v.attached.join(', ') : '—' }}</div>
|
|
444
464
|
<div class="o-td o-faint o-sm">{{ backups(v) }}</div>
|
|
@@ -509,7 +529,13 @@ class ServicesNewPage {
|
|
|
509
529
|
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
510
530
|
this.engines = computed(() => this.catalog()?.engines ?? [], ...(ngDevMode ? [{ debugName: "engines" }] : /* istanbul ignore next */ []));
|
|
511
531
|
this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
|
|
512
|
-
|
|
532
|
+
/** The regions on offer (D-54), the organization's picked for you; with one, the region is text. */
|
|
533
|
+
this.regions = computed(() => this.host.regions?.() ?? [], ...(ngDevMode ? [{ debugName: "regions" }] : /* istanbul ignore next */ []));
|
|
534
|
+
this.regionId = signal('', ...(ngDevMode ? [{ debugName: "regionId" }] : /* istanbul ignore next */ []));
|
|
535
|
+
this.region = computed(() => {
|
|
536
|
+
const r = this.regions().find((x) => x.id === this.regionId());
|
|
537
|
+
return r?.name || this.host.project()?.region || "Your organization's region";
|
|
538
|
+
}, ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
|
|
513
539
|
this.versionLabel = computed(() => this.engine()?.versions.find((v) => v.id === this.version())?.label ?? this.version(), ...(ngDevMode ? [{ debugName: "versionLabel" }] : /* istanbul ignore next */ []));
|
|
514
540
|
this.envs = computed(() => {
|
|
515
541
|
const set = new Set(['production', 'staging']);
|
|
@@ -532,6 +558,11 @@ class ServicesNewPage {
|
|
|
532
558
|
return '';
|
|
533
559
|
}, ...(ngDevMode ? [{ debugName: "nameError" }] : /* istanbul ignore next */ []));
|
|
534
560
|
this.key = '';
|
|
561
|
+
effect(() => {
|
|
562
|
+
const rs = this.regions();
|
|
563
|
+
if (!untracked(this.regionId) && rs.length)
|
|
564
|
+
this.regionId.set((rs.find((r) => r.default) ?? rs[0]).id);
|
|
565
|
+
});
|
|
535
566
|
effect(() => {
|
|
536
567
|
const p = this.host.project();
|
|
537
568
|
if (p && p.key !== this.key) {
|
|
@@ -611,6 +642,7 @@ class ServicesNewPage {
|
|
|
611
642
|
this.api
|
|
612
643
|
.create(this.key, {
|
|
613
644
|
name: this.name(), engine: e.id, version: this.version(), plan: p.id, environment: env, backupHour: this.hour(), retentionDays: this.keep(),
|
|
645
|
+
region: this.regions().length > 1 ? this.regionId() || undefined : undefined,
|
|
614
646
|
attach: attach ? { app: attach, environment: env || 'production' } : undefined, standby: this.ha() && !!e.ha,
|
|
615
647
|
})
|
|
616
648
|
.subscribe({
|
|
@@ -672,7 +704,16 @@ class ServicesNewPage {
|
|
|
672
704
|
</div></div>
|
|
673
705
|
@if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
|
|
674
706
|
<div class="o-g2">
|
|
675
|
-
<div class="o-fld"><label
|
|
707
|
+
<div class="o-fld"><label for="svc-region">Region</label>
|
|
708
|
+
@if (regions().length > 1) {
|
|
709
|
+
<select id="svc-region" class="o-in" [ngModel]="regionId()" (ngModelChange)="regionId.set($event)" data-testid="svc-region">
|
|
710
|
+
@for (r of regions(); track r.id) {<option [value]="r.id">{{ r.name }}{{ r.default ? ' (your organization’s)' : '' }}</option>}
|
|
711
|
+
</select>
|
|
712
|
+
<span class="o-hint">It stays in this region. Apps attach to it only from an environment in the same region.</span>
|
|
713
|
+
} @else {
|
|
714
|
+
<input id="svc-region" class="o-in" [value]="region()" readonly data-testid="svc-region-one" />
|
|
715
|
+
}
|
|
716
|
+
</div>
|
|
676
717
|
<div class="o-fld"><label>High availability</label><div class="o-flex">
|
|
677
718
|
@if (engine()?.ha) {<button type="button" class="o-tog" [class.o-on]="ha()" (click)="ha.set(!ha())" [disabled]="!!quota()?.onlyPlan" aria-label="High availability" data-testid="svc-ha"></button>}
|
|
678
719
|
@else {<button type="button" class="o-tog" disabled aria-label="High availability"></button>}
|
|
@@ -737,7 +778,7 @@ class ServicesNewPage {
|
|
|
737
778
|
<span class="o-k">Backups</span><span>{{ engine()?.id === 'mysql' ? 'Hourly' : 'Daily' }}{{ engine()?.pitr ? ' + PITR' : engine()?.id === 'nats' ? ' stream snapshot' : engine()?.id === 'rabbitmq' ? ' definitions' : ' RDB snapshot' }}</span>
|
|
738
779
|
</div>
|
|
739
780
|
<div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md" data-testid="svc-monthly">{{ plan() ? price(plan()!.priceCents * nodes()) + '/mo' : '' }}</b></div>
|
|
740
|
-
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}
|
|
781
|
+
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }} and no more.</span>
|
|
741
782
|
</div>
|
|
742
783
|
</div>
|
|
743
784
|
</div>
|
|
@@ -796,7 +837,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
796
837
|
</div></div>
|
|
797
838
|
@if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
|
|
798
839
|
<div class="o-g2">
|
|
799
|
-
<div class="o-fld"><label
|
|
840
|
+
<div class="o-fld"><label for="svc-region">Region</label>
|
|
841
|
+
@if (regions().length > 1) {
|
|
842
|
+
<select id="svc-region" class="o-in" [ngModel]="regionId()" (ngModelChange)="regionId.set($event)" data-testid="svc-region">
|
|
843
|
+
@for (r of regions(); track r.id) {<option [value]="r.id">{{ r.name }}{{ r.default ? ' (your organization’s)' : '' }}</option>}
|
|
844
|
+
</select>
|
|
845
|
+
<span class="o-hint">It stays in this region. Apps attach to it only from an environment in the same region.</span>
|
|
846
|
+
} @else {
|
|
847
|
+
<input id="svc-region" class="o-in" [value]="region()" readonly data-testid="svc-region-one" />
|
|
848
|
+
}
|
|
849
|
+
</div>
|
|
800
850
|
<div class="o-fld"><label>High availability</label><div class="o-flex">
|
|
801
851
|
@if (engine()?.ha) {<button type="button" class="o-tog" [class.o-on]="ha()" (click)="ha.set(!ha())" [disabled]="!!quota()?.onlyPlan" aria-label="High availability" data-testid="svc-ha"></button>}
|
|
802
852
|
@else {<button type="button" class="o-tog" disabled aria-label="High availability"></button>}
|
|
@@ -861,7 +911,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
861
911
|
<span class="o-k">Backups</span><span>{{ engine()?.id === 'mysql' ? 'Hourly' : 'Daily' }}{{ engine()?.pitr ? ' + PITR' : engine()?.id === 'nats' ? ' stream snapshot' : engine()?.id === 'rabbitmq' ? ' definitions' : ' RDB snapshot' }}</span>
|
|
862
912
|
</div>
|
|
863
913
|
<div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md" data-testid="svc-monthly">{{ plan() ? price(plan()!.priceCents * nodes()) + '/mo' : '' }}</b></div>
|
|
864
|
-
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}
|
|
914
|
+
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }} and no more.</span>
|
|
865
915
|
</div>
|
|
866
916
|
</div>
|
|
867
917
|
</div>
|
|
@@ -878,6 +928,8 @@ class ServiceConsoleTab {
|
|
|
878
928
|
this.api = inject(ServicesApi);
|
|
879
929
|
this.d = input.required(...(ngDevMode ? [{ debugName: "d" }] : /* istanbul ignore next */ []));
|
|
880
930
|
this.projectKey = input.required(...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
|
|
931
|
+
/** The console and the inspector need services.connect on the instance's environment (V4-01). */
|
|
932
|
+
this.may = computed(() => (this.d().caps ?? []).includes('services.connect'), ...(ngDevMode ? [{ debugName: "may" }] : /* istanbul ignore next */ []));
|
|
881
933
|
this.kind = input('sql', ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
|
|
882
934
|
this.sql = computed(() => this.kind() === 'sql', ...(ngDevMode ? [{ debugName: "sql" }] : /* istanbul ignore next */ []));
|
|
883
935
|
this.running = computed(() => ['Running', 'Updating', 'Degraded'].includes(this.d().state), ...(ngDevMode ? [{ debugName: "running" }] : /* istanbul ignore next */ []));
|
|
@@ -964,12 +1016,14 @@ class ServiceConsoleTab {
|
|
|
964
1016
|
}
|
|
965
1017
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceConsoleTab, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
966
1018
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceConsoleTab, isStandalone: true, selector: "o-svc-console", inputs: { d: { classPropertyName: "d", publicName: "d", isSignal: true, isRequired: true, transformFunction: null }, projectKey: { classPropertyName: "projectKey", publicName: "projectKey", isSignal: true, isRequired: true, transformFunction: null }, kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
967
|
-
@if (
|
|
1019
|
+
@if (!may()) {
|
|
1020
|
+
<div class="o-empty" data-testid="svc-console-denied">Your role cannot open the console in {{ d().environment }}: it needs “Connect and move data” (services.connect).</div>
|
|
1021
|
+
} @else if (sql()) {
|
|
968
1022
|
<div class="o-flex o-wrap">
|
|
969
1023
|
<span class="o-chip" [class.o-grn]="!write()" [class.o-amb]="write()" data-testid="svc-console-mode">{{ write() ? 'Read-write' : 'Read-only' }}</span>
|
|
970
1024
|
<span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as {{ runsAs() }} · audited</span>
|
|
971
1025
|
<span class="o-r"></span>
|
|
972
|
-
@if (
|
|
1026
|
+
@if (may()) {<label class="o-flex o-sm" style="gap:6px"><input type="checkbox" [ngModel]="write()" (ngModelChange)="write.set($event)" data-testid="svc-console-write" /> Allow writes</label>}
|
|
973
1027
|
@if (d().databases.length) {
|
|
974
1028
|
<select class="o-in o-sm" style="width:auto" aria-label="Database" [ngModel]="db() || d().databases[0]?.name" (ngModelChange)="db.set($event)" data-testid="svc-console-db">
|
|
975
1029
|
@for (x of d().databases; track x.name) {<option [value]="x.name">{{ x.name }}</option>}
|
|
@@ -1027,12 +1081,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1027
1081
|
imports: [FormsModule],
|
|
1028
1082
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1029
1083
|
template: `
|
|
1030
|
-
@if (
|
|
1084
|
+
@if (!may()) {
|
|
1085
|
+
<div class="o-empty" data-testid="svc-console-denied">Your role cannot open the console in {{ d().environment }}: it needs “Connect and move data” (services.connect).</div>
|
|
1086
|
+
} @else if (sql()) {
|
|
1031
1087
|
<div class="o-flex o-wrap">
|
|
1032
1088
|
<span class="o-chip" [class.o-grn]="!write()" [class.o-amb]="write()" data-testid="svc-console-mode">{{ write() ? 'Read-write' : 'Read-only' }}</span>
|
|
1033
1089
|
<span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as {{ runsAs() }} · audited</span>
|
|
1034
1090
|
<span class="o-r"></span>
|
|
1035
|
-
@if (
|
|
1091
|
+
@if (may()) {<label class="o-flex o-sm" style="gap:6px"><input type="checkbox" [ngModel]="write()" (ngModelChange)="write.set($event)" data-testid="svc-console-write" /> Allow writes</label>}
|
|
1036
1092
|
@if (d().databases.length) {
|
|
1037
1093
|
<select class="o-in o-sm" style="width:auto" aria-label="Database" [ngModel]="db() || d().databases[0]?.name" (ngModelChange)="db.set($event)" data-testid="svc-console-db">
|
|
1038
1094
|
@for (x of d().databases; track x.name) {<option [value]="x.name">{{ x.name }}</option>}
|
|
@@ -1105,6 +1161,10 @@ class ServiceDumpsCard {
|
|
|
1105
1161
|
this.size = bytes;
|
|
1106
1162
|
this.when = (s) => when(s);
|
|
1107
1163
|
}
|
|
1164
|
+
/** Whether the caller holds capability id on the instance's environment (V4-01). */
|
|
1165
|
+
may(id) {
|
|
1166
|
+
return (this.d().caps ?? []).includes(id);
|
|
1167
|
+
}
|
|
1108
1168
|
hasBackup() {
|
|
1109
1169
|
return (this.d().backups ?? []).some((b) => b.phase === 'completed');
|
|
1110
1170
|
}
|
|
@@ -1165,7 +1225,7 @@ class ServiceDumpsCard {
|
|
|
1165
1225
|
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-fork-card">
|
|
1166
1226
|
<b>Fork for staging</b>
|
|
1167
1227
|
<span class="o-hint">Copy from the latest backup into a new instance. {{ d().name }} keeps running untouched.</span>
|
|
1168
|
-
@if (
|
|
1228
|
+
@if (may('services.data.export')) {
|
|
1169
1229
|
<div class="o-flex o-wrap">
|
|
1170
1230
|
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-fork-env">
|
|
1171
1231
|
@for (e of envs; track e) {<option [value]="e">{{ e }}</option>}
|
|
@@ -1177,18 +1237,18 @@ class ServiceDumpsCard {
|
|
|
1177
1237
|
</div>
|
|
1178
1238
|
@if (dumps()) {
|
|
1179
1239
|
<div class="o-card" data-testid="svc-dumps">
|
|
1180
|
-
<div class="o-ch"><b>Dumps</b>@if (
|
|
1240
|
+
<div class="o-ch"><b>Dumps</b>@if (may('services.data.export')) {<button type="button" class="o-btn o-xs o-r" (click)="exportDump()" [disabled]="busy() || d().state !== 'Running'" data-testid="svc-export">{{ tool() }}</button>}</div>
|
|
1181
1241
|
@for (x of d().dumps; track x.id) {
|
|
1182
1242
|
<div class="o-row" [attr.data-testid]="'svc-dump-' + x.id">
|
|
1183
1243
|
<span [class]="'o-dot ' + dot(x)"></span>
|
|
1184
1244
|
<span class="o-sm o-ell" style="flex:1">{{ x.kind === 'export' ? 'Export of ' + x.database : 'Import into ' + x.database + (x.remote ? ' from ' + x.remote : x.fileName ? ' from ' + x.fileName : '') }}</span>
|
|
1185
1245
|
<span class="o-faint o-xs">{{ x.phase === 'completed' ? (x.bytes ? size(x.bytes) + ' · ' : '') + when(x.stoppedAt || x.createdAt) : x.phase }}</span>
|
|
1186
|
-
@if (x.kind === 'export' && x.phase === 'completed') {<button type="button" class="o-lnk" (click)="download(x)" [disabled]="busy()" data-testid="svc-dump-download">Download</button>}
|
|
1187
|
-
@if (
|
|
1246
|
+
@if (x.kind === 'export' && x.phase === 'completed' && may('services.data.export')) {<button type="button" class="o-lnk" (click)="download(x)" [disabled]="busy()" data-testid="svc-dump-download">Download</button>}
|
|
1247
|
+
@if (may('services.data.export') && x.phase !== 'running') {<button type="button" class="o-lnk o-faint" (click)="remove(x)" [attr.aria-label]="'Delete dump ' + x.id" [disabled]="busy()">×</button>}
|
|
1188
1248
|
</div>
|
|
1189
1249
|
@if (x.error) {<div class="o-row o-sm" style="color:var(--o-bad-tx)">{{ x.error }}</div>}
|
|
1190
1250
|
} @empty {<div class="o-empty">No dumps yet. Export one to download it, or import a dump into {{ d().name }}.</div>}
|
|
1191
|
-
@if (
|
|
1251
|
+
@if (may('services.data.import')) {
|
|
1192
1252
|
<div class="o-cb o-col" style="gap:8px;border-top:1px solid var(--o-line-2)">
|
|
1193
1253
|
<b class="o-sm">Import</b>
|
|
1194
1254
|
<div class="o-flex o-wrap">
|
|
@@ -1217,7 +1277,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1217
1277
|
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-fork-card">
|
|
1218
1278
|
<b>Fork for staging</b>
|
|
1219
1279
|
<span class="o-hint">Copy from the latest backup into a new instance. {{ d().name }} keeps running untouched.</span>
|
|
1220
|
-
@if (
|
|
1280
|
+
@if (may('services.data.export')) {
|
|
1221
1281
|
<div class="o-flex o-wrap">
|
|
1222
1282
|
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-fork-env">
|
|
1223
1283
|
@for (e of envs; track e) {<option [value]="e">{{ e }}</option>}
|
|
@@ -1229,18 +1289,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1229
1289
|
</div>
|
|
1230
1290
|
@if (dumps()) {
|
|
1231
1291
|
<div class="o-card" data-testid="svc-dumps">
|
|
1232
|
-
<div class="o-ch"><b>Dumps</b>@if (
|
|
1292
|
+
<div class="o-ch"><b>Dumps</b>@if (may('services.data.export')) {<button type="button" class="o-btn o-xs o-r" (click)="exportDump()" [disabled]="busy() || d().state !== 'Running'" data-testid="svc-export">{{ tool() }}</button>}</div>
|
|
1233
1293
|
@for (x of d().dumps; track x.id) {
|
|
1234
1294
|
<div class="o-row" [attr.data-testid]="'svc-dump-' + x.id">
|
|
1235
1295
|
<span [class]="'o-dot ' + dot(x)"></span>
|
|
1236
1296
|
<span class="o-sm o-ell" style="flex:1">{{ x.kind === 'export' ? 'Export of ' + x.database : 'Import into ' + x.database + (x.remote ? ' from ' + x.remote : x.fileName ? ' from ' + x.fileName : '') }}</span>
|
|
1237
1297
|
<span class="o-faint o-xs">{{ x.phase === 'completed' ? (x.bytes ? size(x.bytes) + ' · ' : '') + when(x.stoppedAt || x.createdAt) : x.phase }}</span>
|
|
1238
|
-
@if (x.kind === 'export' && x.phase === 'completed') {<button type="button" class="o-lnk" (click)="download(x)" [disabled]="busy()" data-testid="svc-dump-download">Download</button>}
|
|
1239
|
-
@if (
|
|
1298
|
+
@if (x.kind === 'export' && x.phase === 'completed' && may('services.data.export')) {<button type="button" class="o-lnk" (click)="download(x)" [disabled]="busy()" data-testid="svc-dump-download">Download</button>}
|
|
1299
|
+
@if (may('services.data.export') && x.phase !== 'running') {<button type="button" class="o-lnk o-faint" (click)="remove(x)" [attr.aria-label]="'Delete dump ' + x.id" [disabled]="busy()">×</button>}
|
|
1240
1300
|
</div>
|
|
1241
1301
|
@if (x.error) {<div class="o-row o-sm" style="color:var(--o-bad-tx)">{{ x.error }}</div>}
|
|
1242
1302
|
} @empty {<div class="o-empty">No dumps yet. Export one to download it, or import a dump into {{ d().name }}.</div>}
|
|
1243
|
-
@if (
|
|
1303
|
+
@if (may('services.data.import')) {
|
|
1244
1304
|
<div class="o-cb o-col" style="gap:8px;border-top:1px solid var(--o-line-2)">
|
|
1245
1305
|
<b class="o-sm">Import</b>
|
|
1246
1306
|
<div class="o-flex o-wrap">
|
|
@@ -1538,6 +1598,351 @@ function show(f) {
|
|
|
1538
1598
|
return num(f.value);
|
|
1539
1599
|
}
|
|
1540
1600
|
|
|
1601
|
+
/** Runs an action with a busy flag; errors go to failed. */
|
|
1602
|
+
class Card {
|
|
1603
|
+
constructor() {
|
|
1604
|
+
this.api = inject(ServicesApi);
|
|
1605
|
+
this.d = input.required(...(ngDevMode ? [{ debugName: "d" }] : /* istanbul ignore next */ []));
|
|
1606
|
+
this.projectKey = input.required(...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
|
|
1607
|
+
this.done = output();
|
|
1608
|
+
this.failed = output();
|
|
1609
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1610
|
+
this.when = (s) => when(s);
|
|
1611
|
+
}
|
|
1612
|
+
may(id) {
|
|
1613
|
+
return (this.d().caps ?? []).includes(id);
|
|
1614
|
+
}
|
|
1615
|
+
go(obs, ok, fail) {
|
|
1616
|
+
this.busy.set(true);
|
|
1617
|
+
obs.subscribe({
|
|
1618
|
+
next: (v) => {
|
|
1619
|
+
this.busy.set(false);
|
|
1620
|
+
ok(v);
|
|
1621
|
+
},
|
|
1622
|
+
error: (e) => {
|
|
1623
|
+
this.busy.set(false);
|
|
1624
|
+
this.failed.emit(apiMessage(e, fail));
|
|
1625
|
+
},
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: Card, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
1629
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.12", type: Card, isStandalone: true, inputs: { d: { classPropertyName: "d", publicName: "d", isSignal: true, isRequired: true, transformFunction: null }, projectKey: { classPropertyName: "projectKey", publicName: "projectKey", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { done: "done", failed: "failed" }, ngImport: i0 }); }
|
|
1630
|
+
}
|
|
1631
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: Card, decorators: [{
|
|
1632
|
+
type: Directive
|
|
1633
|
+
}], propDecorators: { d: [{ type: i0.Input, args: [{ isSignal: true, alias: "d", required: true }] }], projectKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "projectKey", required: true }] }], done: [{ type: i0.Output, args: ["done"] }], failed: [{ type: i0.Output, args: ["failed"] }] } });
|
|
1634
|
+
const UPGRADE_STATE = {
|
|
1635
|
+
scheduled: 'Scheduled', 'backing-up': 'Taking the pre-upgrade backup', upgrading: 'Upgrading', testing: 'Building the new instance',
|
|
1636
|
+
ready: 'Ready to swap', done: 'Done', failed: 'Failed', canceled: 'Canceled',
|
|
1637
|
+
};
|
|
1638
|
+
// Settings › Major version (V8-01, SVC-08): what the instance can be upgraded to and how (in
|
|
1639
|
+
// place, or into a new instance for testing), now or in the maintenance window with a week's
|
|
1640
|
+
// notice; the upgrade in progress, with its pre-upgrade backup (the way back); refusals with the reason.
|
|
1641
|
+
class ServiceUpgradeCard extends Card {
|
|
1642
|
+
constructor() {
|
|
1643
|
+
super(...arguments);
|
|
1644
|
+
this.router = inject(Router);
|
|
1645
|
+
this.host = inject(ServicesHost);
|
|
1646
|
+
this.method = signal('in-place', ...(ngDevMode ? [{ debugName: "method" }] : /* istanbul ignore next */ []));
|
|
1647
|
+
this.whenTo = signal('window', ...(ngDevMode ? [{ debugName: "whenTo" }] : /* istanbul ignore next */ []));
|
|
1648
|
+
}
|
|
1649
|
+
open(s) {
|
|
1650
|
+
return !['done', 'failed', 'canceled'].includes(s);
|
|
1651
|
+
}
|
|
1652
|
+
stateLabel(s) {
|
|
1653
|
+
return UPGRADE_STATE[s] ?? s;
|
|
1654
|
+
}
|
|
1655
|
+
chip(s) {
|
|
1656
|
+
return s === 'done' || s === 'ready' ? 'o-grn' : s === 'failed' ? 'o-red' : s === 'canceled' || s === 'scheduled' ? 'o-amb' : 'o-blu';
|
|
1657
|
+
}
|
|
1658
|
+
start() {
|
|
1659
|
+
const u = this.d().upgrade;
|
|
1660
|
+
const m = u?.offer.methods.includes(this.method()) ? this.method() : (u?.offer.methods[0] ?? 'in-place');
|
|
1661
|
+
this.go(this.api.upgrade(this.projectKey(), this.d().id, { version: u?.offer.to, method: m, when: this.whenTo() }), (x) => {
|
|
1662
|
+
const c = x.upgrade?.current;
|
|
1663
|
+
this.done.emit(c?.scheduled ? `Upgrade to ${c.to} scheduled for ${when(c.at)}; the people concerned are told now.` : `Upgrading to ${c?.to}: the pre-upgrade backup runs first.`);
|
|
1664
|
+
}, 'The upgrade could not be started.');
|
|
1665
|
+
}
|
|
1666
|
+
cancel() {
|
|
1667
|
+
this.go(this.api.cancelUpgrade(this.projectKey(), this.d().id), () => this.done.emit('Upgrade canceled.'), 'The upgrade could not be canceled.');
|
|
1668
|
+
}
|
|
1669
|
+
openTarget(name) {
|
|
1670
|
+
this.router.navigate(this.host.link('services/' + name));
|
|
1671
|
+
}
|
|
1672
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceUpgradeCard, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
|
|
1673
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceUpgradeCard, isStandalone: true, selector: "o-svc-upgrade", usesInheritance: true, ngImport: i0, template: `
|
|
1674
|
+
@let u = d().upgrade;
|
|
1675
|
+
<div class="o-card" data-testid="svc-upgrade">
|
|
1676
|
+
<div class="o-ch"><b>Major version</b><span class="o-r">{{ d().engineName }} {{ u?.running || d().version }}</span></div>
|
|
1677
|
+
<div class="o-cb o-col" style="gap:8px">
|
|
1678
|
+
@if (u?.current; as c) {
|
|
1679
|
+
<div class="o-flex o-wrap" data-testid="svc-upgrade-current">
|
|
1680
|
+
<span [class]="'o-chip ' + chip(c.state)">{{ stateLabel(c.state) }}</span>
|
|
1681
|
+
<span class="o-sm">{{ c.from }} to {{ c.to }}, {{ c.method === 'new-instance' ? 'into a new instance' : 'in place' }}</span>
|
|
1682
|
+
@if (c.state === 'scheduled') {<span class="o-faint o-sm">starts {{ when(c.at) }}</span>}
|
|
1683
|
+
@if (open(c.state) && (c.state === 'scheduled' || c.state === 'backing-up') && may('services.instance.configure')) {
|
|
1684
|
+
<button type="button" class="o-btn o-sm o-r" (click)="cancel()" [disabled]="busy()" data-testid="svc-upgrade-cancel">Cancel upgrade</button>
|
|
1685
|
+
}
|
|
1686
|
+
@if (c.state === 'ready' && c.targetName) {<button type="button" class="o-btn o-sm o-r" (click)="openTarget(c.targetName)">Open {{ c.targetName }}</button>}
|
|
1687
|
+
</div>
|
|
1688
|
+
@if (c.message) {<span class="o-hint">{{ c.message }}</span>}
|
|
1689
|
+
@if (c.backupName && c.state !== 'canceled') {<span class="o-faint o-xs">Pre-upgrade backup: {{ c.backupName }}. To go back, restore it into a new instance on {{ c.from }}.</span>}
|
|
1690
|
+
}
|
|
1691
|
+
@if (!u?.current || !open(u!.current!.state)) {
|
|
1692
|
+
@if (u?.offer?.refused) {
|
|
1693
|
+
<span class="o-hint" data-testid="svc-upgrade-refused">{{ u!.offer.refused }}</span>
|
|
1694
|
+
} @else if (u?.offer?.to) {
|
|
1695
|
+
<span class="o-sm">Upgrade to <b>{{ d().engineName }} {{ u!.offer.to }}</b></span>
|
|
1696
|
+
@for (n of u!.offer.notes; track n) {<span class="o-faint o-xs">{{ n }}</span>}
|
|
1697
|
+
@if (may('services.instance.configure')) {
|
|
1698
|
+
<div class="o-flex o-wrap">
|
|
1699
|
+
<select class="o-in o-sm" style="width:auto" aria-label="How" [ngModel]="method()" (ngModelChange)="method.set($event)" data-testid="svc-upgrade-method">
|
|
1700
|
+
@for (m of u!.offer.methods; track m) {<option [value]="m">{{ m === 'new-instance' ? 'Into a new instance, for testing' : 'In place' }}</option>}
|
|
1701
|
+
</select>
|
|
1702
|
+
<select class="o-in o-sm" style="width:auto" aria-label="When" [ngModel]="whenTo()" (ngModelChange)="whenTo.set($event)" data-testid="svc-upgrade-when">
|
|
1703
|
+
<option value="window">In the maintenance window, with a week's notice</option>
|
|
1704
|
+
<option value="now">Now</option>
|
|
1705
|
+
</select>
|
|
1706
|
+
<button type="button" class="o-btn o-sm o-dark o-r" (click)="start()" [disabled]="busy() || d().state !== 'Running'" data-testid="svc-upgrade-go">Upgrade to {{ u!.offer.to }}</button>
|
|
1707
|
+
</div>
|
|
1708
|
+
<span class="o-faint o-xs">A backup is taken first and kept until you delete it.</span>
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
</div>
|
|
1713
|
+
</div>
|
|
1714
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1715
|
+
}
|
|
1716
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceUpgradeCard, decorators: [{
|
|
1717
|
+
type: Component,
|
|
1718
|
+
args: [{
|
|
1719
|
+
selector: 'o-svc-upgrade',
|
|
1720
|
+
imports: [FormsModule],
|
|
1721
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1722
|
+
template: `
|
|
1723
|
+
@let u = d().upgrade;
|
|
1724
|
+
<div class="o-card" data-testid="svc-upgrade">
|
|
1725
|
+
<div class="o-ch"><b>Major version</b><span class="o-r">{{ d().engineName }} {{ u?.running || d().version }}</span></div>
|
|
1726
|
+
<div class="o-cb o-col" style="gap:8px">
|
|
1727
|
+
@if (u?.current; as c) {
|
|
1728
|
+
<div class="o-flex o-wrap" data-testid="svc-upgrade-current">
|
|
1729
|
+
<span [class]="'o-chip ' + chip(c.state)">{{ stateLabel(c.state) }}</span>
|
|
1730
|
+
<span class="o-sm">{{ c.from }} to {{ c.to }}, {{ c.method === 'new-instance' ? 'into a new instance' : 'in place' }}</span>
|
|
1731
|
+
@if (c.state === 'scheduled') {<span class="o-faint o-sm">starts {{ when(c.at) }}</span>}
|
|
1732
|
+
@if (open(c.state) && (c.state === 'scheduled' || c.state === 'backing-up') && may('services.instance.configure')) {
|
|
1733
|
+
<button type="button" class="o-btn o-sm o-r" (click)="cancel()" [disabled]="busy()" data-testid="svc-upgrade-cancel">Cancel upgrade</button>
|
|
1734
|
+
}
|
|
1735
|
+
@if (c.state === 'ready' && c.targetName) {<button type="button" class="o-btn o-sm o-r" (click)="openTarget(c.targetName)">Open {{ c.targetName }}</button>}
|
|
1736
|
+
</div>
|
|
1737
|
+
@if (c.message) {<span class="o-hint">{{ c.message }}</span>}
|
|
1738
|
+
@if (c.backupName && c.state !== 'canceled') {<span class="o-faint o-xs">Pre-upgrade backup: {{ c.backupName }}. To go back, restore it into a new instance on {{ c.from }}.</span>}
|
|
1739
|
+
}
|
|
1740
|
+
@if (!u?.current || !open(u!.current!.state)) {
|
|
1741
|
+
@if (u?.offer?.refused) {
|
|
1742
|
+
<span class="o-hint" data-testid="svc-upgrade-refused">{{ u!.offer.refused }}</span>
|
|
1743
|
+
} @else if (u?.offer?.to) {
|
|
1744
|
+
<span class="o-sm">Upgrade to <b>{{ d().engineName }} {{ u!.offer.to }}</b></span>
|
|
1745
|
+
@for (n of u!.offer.notes; track n) {<span class="o-faint o-xs">{{ n }}</span>}
|
|
1746
|
+
@if (may('services.instance.configure')) {
|
|
1747
|
+
<div class="o-flex o-wrap">
|
|
1748
|
+
<select class="o-in o-sm" style="width:auto" aria-label="How" [ngModel]="method()" (ngModelChange)="method.set($event)" data-testid="svc-upgrade-method">
|
|
1749
|
+
@for (m of u!.offer.methods; track m) {<option [value]="m">{{ m === 'new-instance' ? 'Into a new instance, for testing' : 'In place' }}</option>}
|
|
1750
|
+
</select>
|
|
1751
|
+
<select class="o-in o-sm" style="width:auto" aria-label="When" [ngModel]="whenTo()" (ngModelChange)="whenTo.set($event)" data-testid="svc-upgrade-when">
|
|
1752
|
+
<option value="window">In the maintenance window, with a week's notice</option>
|
|
1753
|
+
<option value="now">Now</option>
|
|
1754
|
+
</select>
|
|
1755
|
+
<button type="button" class="o-btn o-sm o-dark o-r" (click)="start()" [disabled]="busy() || d().state !== 'Running'" data-testid="svc-upgrade-go">Upgrade to {{ u!.offer.to }}</button>
|
|
1756
|
+
</div>
|
|
1757
|
+
<span class="o-faint o-xs">A backup is taken first and kept until you delete it.</span>
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
</div>
|
|
1762
|
+
</div>
|
|
1763
|
+
`,
|
|
1764
|
+
}]
|
|
1765
|
+
}] });
|
|
1766
|
+
// Backups › Copies in another region (V8-02, O10): opt-in, naming where the copies go; the
|
|
1767
|
+
// last copy; a restore from the copy into a new instance in that region.
|
|
1768
|
+
class ServiceCopiesCard extends Card {
|
|
1769
|
+
constructor() {
|
|
1770
|
+
super(...arguments);
|
|
1771
|
+
this.router = inject(Router);
|
|
1772
|
+
this.host = inject(ServicesHost);
|
|
1773
|
+
this.target = signal('', ...(ngDevMode ? [{ debugName: "target" }] : /* istanbul ignore next */ []));
|
|
1774
|
+
this.backup = signal('', ...(ngDevMode ? [{ debugName: "backup" }] : /* istanbul ignore next */ []));
|
|
1775
|
+
this.size = bytes;
|
|
1776
|
+
}
|
|
1777
|
+
regionName(id) {
|
|
1778
|
+
return this.d().copy?.offered.find((r) => r.id === id)?.name ?? id;
|
|
1779
|
+
}
|
|
1780
|
+
/** Completed backups the last copy holds. */
|
|
1781
|
+
copied() {
|
|
1782
|
+
const through = this.d().copy?.last?.through;
|
|
1783
|
+
if (!through)
|
|
1784
|
+
return [];
|
|
1785
|
+
return (this.d().backups ?? []).filter((b) => b.phase === 'completed' && b.stoppedAt && b.stoppedAt <= through);
|
|
1786
|
+
}
|
|
1787
|
+
set(region) {
|
|
1788
|
+
const name = region ? this.regionName(region) : this.d().copy?.regionName;
|
|
1789
|
+
this.go(this.api.update(this.projectKey(), this.d().id, { copyRegion: region }), () => {
|
|
1790
|
+
this.target.set('');
|
|
1791
|
+
this.done.emit(region ? `Backups of ${this.d().name} are now copied to ${name}.` : `Copies stopped; the copies in ${name} are deleted.`);
|
|
1792
|
+
}, 'The copies could not be changed.');
|
|
1793
|
+
}
|
|
1794
|
+
restore() {
|
|
1795
|
+
const b = this.backup() || this.copied().at(-1)?.name || '';
|
|
1796
|
+
const c = this.d().copy;
|
|
1797
|
+
if (!b || !c)
|
|
1798
|
+
return;
|
|
1799
|
+
this.go(this.api.restore(this.projectKey(), this.d().id, { backup: b, region: c.region }), (x) => this.router.navigate(this.host.link('services/' + x.name)), 'The restore could not be started.');
|
|
1800
|
+
}
|
|
1801
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceCopiesCard, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
|
|
1802
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceCopiesCard, isStandalone: true, selector: "o-svc-copies", usesInheritance: true, ngImport: i0, template: `
|
|
1803
|
+
@let c = d().copy;
|
|
1804
|
+
@if (c) {
|
|
1805
|
+
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-copies">
|
|
1806
|
+
<b>Copies in another region</b>
|
|
1807
|
+
@if (c.region) {
|
|
1808
|
+
<span class="o-sm">Backups are copied from {{ d().regionName || d().region }} to <b>{{ c.regionName }}</b>.</span>
|
|
1809
|
+
@if (c.last; as l) {
|
|
1810
|
+
<span class="o-faint o-xs" data-testid="svc-copies-last">{{ l.error ? 'Last copy failed: ' + l.error : 'Last copied ' + when(l.at) + ' · ' + l.objects + ' objects · ' + size(l.bytes) }}</span>
|
|
1811
|
+
} @else {<span class="o-faint o-xs">The first copy is on its way.</span>}
|
|
1812
|
+
<div class="o-flex o-wrap">
|
|
1813
|
+
@if (may('services.data.import') && c.last?.through) {
|
|
1814
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Backup" [ngModel]="backup()" (ngModelChange)="backup.set($event)" data-testid="svc-copies-backup">
|
|
1815
|
+
@for (b of copied(); track b.name) {<option [value]="b.name">{{ when(b.startedAt) }}</option>}
|
|
1816
|
+
</select>
|
|
1817
|
+
<button type="button" class="o-btn o-sm" (click)="restore()" [disabled]="busy() || !copied().length" data-testid="svc-copies-restore">Restore in {{ c.regionName }}</button>
|
|
1818
|
+
}
|
|
1819
|
+
@if (may('services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="set('')" [disabled]="busy()" data-testid="svc-copies-off">Stop copying</button>}
|
|
1820
|
+
</div>
|
|
1821
|
+
<span class="o-faint o-xs">Stopping deletes the copies in {{ c.regionName }}.</span>
|
|
1822
|
+
} @else {
|
|
1823
|
+
<span class="o-hint">Keep a copy of every backup in another region, to restore there if this region is lost. The copies leave {{ d().regionName || d().region }}.</span>
|
|
1824
|
+
@if (may('services.instance.configure') && c.offered.length) {
|
|
1825
|
+
<div class="o-flex o-wrap">
|
|
1826
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Copy to" [ngModel]="target()" (ngModelChange)="target.set($event)" data-testid="svc-copies-region">
|
|
1827
|
+
<option value="">Choose a region</option>
|
|
1828
|
+
@for (r of c.offered; track r.id) {<option [value]="r.id">{{ r.name }}</option>}
|
|
1829
|
+
</select>
|
|
1830
|
+
<button type="button" class="o-btn o-sm o-r" (click)="set(target())" [disabled]="busy() || !target()" data-testid="svc-copies-on">{{ target() ? 'Copy backups to ' + regionName(target()) : 'Copy backups' }}</button>
|
|
1831
|
+
</div>
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
</div>
|
|
1835
|
+
}
|
|
1836
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1837
|
+
}
|
|
1838
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceCopiesCard, decorators: [{
|
|
1839
|
+
type: Component,
|
|
1840
|
+
args: [{
|
|
1841
|
+
selector: 'o-svc-copies',
|
|
1842
|
+
imports: [FormsModule],
|
|
1843
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1844
|
+
template: `
|
|
1845
|
+
@let c = d().copy;
|
|
1846
|
+
@if (c) {
|
|
1847
|
+
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-copies">
|
|
1848
|
+
<b>Copies in another region</b>
|
|
1849
|
+
@if (c.region) {
|
|
1850
|
+
<span class="o-sm">Backups are copied from {{ d().regionName || d().region }} to <b>{{ c.regionName }}</b>.</span>
|
|
1851
|
+
@if (c.last; as l) {
|
|
1852
|
+
<span class="o-faint o-xs" data-testid="svc-copies-last">{{ l.error ? 'Last copy failed: ' + l.error : 'Last copied ' + when(l.at) + ' · ' + l.objects + ' objects · ' + size(l.bytes) }}</span>
|
|
1853
|
+
} @else {<span class="o-faint o-xs">The first copy is on its way.</span>}
|
|
1854
|
+
<div class="o-flex o-wrap">
|
|
1855
|
+
@if (may('services.data.import') && c.last?.through) {
|
|
1856
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Backup" [ngModel]="backup()" (ngModelChange)="backup.set($event)" data-testid="svc-copies-backup">
|
|
1857
|
+
@for (b of copied(); track b.name) {<option [value]="b.name">{{ when(b.startedAt) }}</option>}
|
|
1858
|
+
</select>
|
|
1859
|
+
<button type="button" class="o-btn o-sm" (click)="restore()" [disabled]="busy() || !copied().length" data-testid="svc-copies-restore">Restore in {{ c.regionName }}</button>
|
|
1860
|
+
}
|
|
1861
|
+
@if (may('services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="set('')" [disabled]="busy()" data-testid="svc-copies-off">Stop copying</button>}
|
|
1862
|
+
</div>
|
|
1863
|
+
<span class="o-faint o-xs">Stopping deletes the copies in {{ c.regionName }}.</span>
|
|
1864
|
+
} @else {
|
|
1865
|
+
<span class="o-hint">Keep a copy of every backup in another region, to restore there if this region is lost. The copies leave {{ d().regionName || d().region }}.</span>
|
|
1866
|
+
@if (may('services.instance.configure') && c.offered.length) {
|
|
1867
|
+
<div class="o-flex o-wrap">
|
|
1868
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Copy to" [ngModel]="target()" (ngModelChange)="target.set($event)" data-testid="svc-copies-region">
|
|
1869
|
+
<option value="">Choose a region</option>
|
|
1870
|
+
@for (r of c.offered; track r.id) {<option [value]="r.id">{{ r.name }}</option>}
|
|
1871
|
+
</select>
|
|
1872
|
+
<button type="button" class="o-btn o-sm o-r" (click)="set(target())" [disabled]="busy() || !target()" data-testid="svc-copies-on">{{ target() ? 'Copy backups to ' + regionName(target()) : 'Copy backups' }}</button>
|
|
1873
|
+
</div>
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
</div>
|
|
1877
|
+
}
|
|
1878
|
+
`,
|
|
1879
|
+
}]
|
|
1880
|
+
}] });
|
|
1881
|
+
// Settings › Storage (V8-04, D-30): the disk grows by itself near full, up to a limit, where the
|
|
1882
|
+
// volumes can grow in place.
|
|
1883
|
+
class ServiceStorageCard extends Card {
|
|
1884
|
+
save(body) {
|
|
1885
|
+
this.go(this.api.update(this.projectKey(), this.d().id, body), () => this.done.emit('Storage saved.'), 'Storage could not be saved.');
|
|
1886
|
+
}
|
|
1887
|
+
cap(ev) {
|
|
1888
|
+
const v = Math.round(+ev.target.value);
|
|
1889
|
+
if (!Number.isFinite(v))
|
|
1890
|
+
return;
|
|
1891
|
+
this.save({ autoGrowMaxGb: v === this.d().storage?.limitGb && !this.d().storage?.maxGb ? 0 : v });
|
|
1892
|
+
}
|
|
1893
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceStorageCard, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
|
|
1894
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceStorageCard, isStandalone: true, selector: "o-svc-storage", usesInheritance: true, ngImport: i0, template: `
|
|
1895
|
+
@let s = d().storage;
|
|
1896
|
+
@if (s) {
|
|
1897
|
+
<div class="o-card" data-testid="svc-storage">
|
|
1898
|
+
<div class="o-ch"><b>Storage</b><span class="o-r">{{ d().diskGb }} GB</span></div>
|
|
1899
|
+
<div class="o-cb o-col" style="gap:8px">
|
|
1900
|
+
<label class="o-flex o-sm"><input type="checkbox" [ngModel]="s.autoGrow" (ngModelChange)="save({ autoGrow: $event })" [disabled]="busy() || !may('services.instance.configure')" data-testid="svc-autogrow" />Grow the disk automatically when it is {{ s.atPercent }}% full</label>
|
|
1901
|
+
@if (s.autoGrow) {
|
|
1902
|
+
<div class="o-flex o-wrap">
|
|
1903
|
+
<span class="o-sm">Up to</span>
|
|
1904
|
+
<input class="o-in o-sm" style="width:90px" type="number" [min]="d().diskGb" step="5" aria-label="Up to GB" [ngModel]="s.maxGb || s.limitGb" (change)="cap($event)" [disabled]="busy() || !may('services.instance.configure')" data-testid="svc-autogrow-max" />
|
|
1905
|
+
<span class="o-sm">GB</span>
|
|
1906
|
+
@if (s.grownAt) {<span class="o-faint o-xs o-r">last grew {{ when(s.grownAt) }}</span>}
|
|
1907
|
+
</div>
|
|
1908
|
+
<span class="o-faint o-xs">It grows by a quarter, at least 10 GB, without downtime; the people concerned are told each time.</span>
|
|
1909
|
+
}
|
|
1910
|
+
@if (s.expandable === false) {<span class="o-hint" data-testid="svc-autogrow-fixed">The volumes in this region cannot grow in place: you are told when the disk is nearly full. {{ s.message }}</span>}
|
|
1911
|
+
</div>
|
|
1912
|
+
</div>
|
|
1913
|
+
}
|
|
1914
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1915
|
+
}
|
|
1916
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceStorageCard, decorators: [{
|
|
1917
|
+
type: Component,
|
|
1918
|
+
args: [{
|
|
1919
|
+
selector: 'o-svc-storage',
|
|
1920
|
+
imports: [FormsModule],
|
|
1921
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1922
|
+
template: `
|
|
1923
|
+
@let s = d().storage;
|
|
1924
|
+
@if (s) {
|
|
1925
|
+
<div class="o-card" data-testid="svc-storage">
|
|
1926
|
+
<div class="o-ch"><b>Storage</b><span class="o-r">{{ d().diskGb }} GB</span></div>
|
|
1927
|
+
<div class="o-cb o-col" style="gap:8px">
|
|
1928
|
+
<label class="o-flex o-sm"><input type="checkbox" [ngModel]="s.autoGrow" (ngModelChange)="save({ autoGrow: $event })" [disabled]="busy() || !may('services.instance.configure')" data-testid="svc-autogrow" />Grow the disk automatically when it is {{ s.atPercent }}% full</label>
|
|
1929
|
+
@if (s.autoGrow) {
|
|
1930
|
+
<div class="o-flex o-wrap">
|
|
1931
|
+
<span class="o-sm">Up to</span>
|
|
1932
|
+
<input class="o-in o-sm" style="width:90px" type="number" [min]="d().diskGb" step="5" aria-label="Up to GB" [ngModel]="s.maxGb || s.limitGb" (change)="cap($event)" [disabled]="busy() || !may('services.instance.configure')" data-testid="svc-autogrow-max" />
|
|
1933
|
+
<span class="o-sm">GB</span>
|
|
1934
|
+
@if (s.grownAt) {<span class="o-faint o-xs o-r">last grew {{ when(s.grownAt) }}</span>}
|
|
1935
|
+
</div>
|
|
1936
|
+
<span class="o-faint o-xs">It grows by a quarter, at least 10 GB, without downtime; the people concerned are told each time.</span>
|
|
1937
|
+
}
|
|
1938
|
+
@if (s.expandable === false) {<span class="o-hint" data-testid="svc-autogrow-fixed">The volumes in this region cannot grow in place: you are told when the disk is nearly full. {{ s.message }}</span>}
|
|
1939
|
+
</div>
|
|
1940
|
+
</div>
|
|
1941
|
+
}
|
|
1942
|
+
`,
|
|
1943
|
+
}]
|
|
1944
|
+
}] });
|
|
1945
|
+
|
|
1541
1946
|
const TABS = [
|
|
1542
1947
|
{ id: 'overview', label: 'Overview' }, { id: 'users', label: 'Databases and users' }, { id: 'backups', label: 'Backups' }, { id: 'metrics', label: 'Metrics' },
|
|
1543
1948
|
{ id: 'logs', label: 'Logs' }, { id: 'console', label: 'Console' }, { id: 'settings', label: 'Settings' },
|
|
@@ -1603,7 +2008,16 @@ class ServicePage {
|
|
|
1603
2008
|
this.engine = computed(() => this.catalog()?.engines.find((e) => e.id === this.d()?.engine) ?? null, ...(ngDevMode ? [{ debugName: "engine" }] : /* istanbul ignore next */ []));
|
|
1604
2009
|
this.envVar = computed(() => this.engine()?.envVar ?? 'DATABASE_URL', ...(ngDevMode ? [{ debugName: "envVar" }] : /* istanbul ignore next */ []));
|
|
1605
2010
|
this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
|
|
1606
|
-
|
|
2011
|
+
/** Where the instance runs (D-54): its own region, the organization's by default. */
|
|
2012
|
+
this.region = computed(() => this.d()?.regionName || this.d()?.region || this.host.project()?.region || 'your region', ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
|
|
2013
|
+
/** The region of the chosen app's chosen environment, when deploy says (D-54). */
|
|
2014
|
+
this.attRegion = computed(() => this.apps().find((a) => a.name === this.attApp())?.regions?.[this.attEnv()] ?? '', ...(ngDevMode ? [{ debugName: "attRegion" }] : /* istanbul ignore next */ []));
|
|
2015
|
+
/** An app attaches only to a service in its own region (D-54). */
|
|
2016
|
+
this.attOtherRegion = computed(() => {
|
|
2017
|
+
const d = this.d(), r = this.attRegion();
|
|
2018
|
+
return !!d?.region && !!r && r !== d.region;
|
|
2019
|
+
}, ...(ngDevMode ? [{ debugName: "attOtherRegion" }] : /* istanbul ignore next */ []));
|
|
2020
|
+
this.rotationPeriods = [0, 30, 60, 90, 180, 365];
|
|
1607
2021
|
this.conn = computed(() => this.revealed()?.connection ?? this.d()?.connection ?? null, ...(ngDevMode ? [{ debugName: "conn" }] : /* istanbul ignore next */ []));
|
|
1608
2022
|
this.snippetKeys = computed(() => {
|
|
1609
2023
|
const s = this.conn()?.snippets ?? {};
|
|
@@ -1848,6 +2262,19 @@ class ServicePage {
|
|
|
1848
2262
|
this.notice.set('Credential reveal recorded in the audit log.');
|
|
1849
2263
|
}, 'The password could not be revealed.');
|
|
1850
2264
|
}
|
|
2265
|
+
/** Whether the caller holds capability id on the instance's environment (V4-01). */
|
|
2266
|
+
has(d, id) {
|
|
2267
|
+
return (d.caps ?? []).includes(id);
|
|
2268
|
+
}
|
|
2269
|
+
/** A user's scheduled rotation (V0-22): every 30 to 365 days, or never. */
|
|
2270
|
+
setRotation(d, user, days) {
|
|
2271
|
+
this.run(this.api.setRotation(this.key(), d.id, user, days), () => {
|
|
2272
|
+
this.notice.set(days ? `${user}'s password now rotates every ${days} days. Attached apps redeploy with each new one; the old one stays valid until they run on it.` : `${user}'s password no longer rotates on a schedule.`);
|
|
2273
|
+
}, 'The rotation schedule could not be saved.');
|
|
2274
|
+
}
|
|
2275
|
+
rotationLine(u) {
|
|
2276
|
+
return u.rotateEveryDays && u.nextRotation ? 'next ' + shortDate(u.nextRotation) : '';
|
|
2277
|
+
}
|
|
1851
2278
|
attach(d) {
|
|
1852
2279
|
const app = this.attApp().trim();
|
|
1853
2280
|
const body = { app, environment: this.attEnv(), prefix: this.attPrefix().trim(), ownUser: this.attOwn(), database: d.engine === 'rabbitmq' ? this.attVhost() : undefined };
|
|
@@ -1886,6 +2313,15 @@ class ServicePage {
|
|
|
1886
2313
|
removeDb(d, name) {
|
|
1887
2314
|
this.run(this.api.removeDatabase(this.key(), d.id, name), () => this.notice.set(`Database ${name} removed.`), 'The database could not be removed.');
|
|
1888
2315
|
}
|
|
2316
|
+
/** A Postgres extension from the allow-list (V8-04, D-30); what it needs comes with it. */
|
|
2317
|
+
addExt(d, db, name) {
|
|
2318
|
+
if (!name)
|
|
2319
|
+
return;
|
|
2320
|
+
this.run(this.api.addExtension(this.key(), d.id, db, name), () => this.notice.set(`${name} is being created in ${db}.`), 'The extension could not be created.');
|
|
2321
|
+
}
|
|
2322
|
+
dropExt(d, db, name) {
|
|
2323
|
+
this.run(this.api.removeExtension(this.key(), d.id, db, name), () => this.notice.set(`${name} is dropped from ${db}.`), 'The extension could not be dropped.');
|
|
2324
|
+
}
|
|
1889
2325
|
addUser(d) {
|
|
1890
2326
|
const name = this.userName().trim();
|
|
1891
2327
|
if (!name)
|
|
@@ -2071,8 +2507,8 @@ class ServicePage {
|
|
|
2071
2507
|
</div>
|
|
2072
2508
|
<div class="o-acts">
|
|
2073
2509
|
@if (d.management && !d.deletedAt) {<button type="button" class="o-btn" (click)="openManagement(d)" [disabled]="busy() || d.state !== 'Running' && d.state !== 'Updating' && d.state !== 'Degraded'" [title]="d.management === 'read' ? 'Read-only for your role' : 'As the instance administrator'" data-testid="svc-management">Open management UI@if (d.management === 'read') {<span class="o-faint o-xs"> · read-only</span>}</button>}
|
|
2074
|
-
@if (d.
|
|
2075
|
-
@if (d.
|
|
2510
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn" (click)="askRotate(d)" [disabled]="busy() || !d.users?.length" data-testid="svc-rotate">Rotate credentials</button>}
|
|
2511
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn" (click)="setTab('settings')">Resize</button>}
|
|
2076
2512
|
<a class="o-btn" [routerLink]="host.link('dashboards')">Dashboard</a>
|
|
2077
2513
|
</div>
|
|
2078
2514
|
</div>
|
|
@@ -2083,13 +2519,13 @@ class ServicePage {
|
|
|
2083
2519
|
@if (d.deletedAt) {
|
|
2084
2520
|
<div class="o-card o-cb o-flex o-wrap" style="border-color:var(--o-bad-line)" data-testid="svc-deleted">
|
|
2085
2521
|
<div class="o-col" style="gap:2px;flex:1"><b>{{ d.name }} is scheduled for deletion</b><span class="o-hint">Purged {{ when(d.purgeAt) }}. Until then you can undo; attached apps were detached.@if (d.finalBackup) { A final backup is kept for 7 days.}</span></div>
|
|
2086
|
-
@if (d.
|
|
2522
|
+
@if (has(d, 'services.instance.delete')) {<button type="button" class="o-btn" (click)="undelete(d)" [disabled]="busy()" data-testid="svc-undelete">Undo delete</button>}
|
|
2087
2523
|
</div>
|
|
2088
2524
|
}
|
|
2089
2525
|
@if (d.restoreOf; as src) {
|
|
2090
2526
|
<div class="o-card o-cb o-flex o-wrap" data-testid="svc-restored">
|
|
2091
2527
|
<div class="o-col" style="gap:2px;flex:1"><b>Restored from <a [routerLink]="host.link('services/' + src.name)">{{ src.name }}</a></b><span class="o-hint">@if (d.restorePoint) {To {{ when(d.restorePoint) }}.} @else if (d.restoreBackup) {From backup {{ d.restoreBackup }}.} {{ src.name }} is untouched.@if (src.attached.length) { Its apps: {{ src.attached.join(', ') }}.}</span></div>
|
|
2092
|
-
@if (d.
|
|
2528
|
+
@if (has(d, 'services.instance.configure') && src.attached.length && d.state === 'Running') {<button type="button" class="o-btn" (click)="swap(d, src.name)" [disabled]="busy()" data-testid="svc-swap">Move {{ src.name }}'s apps here</button>}
|
|
2093
2529
|
</div>
|
|
2094
2530
|
}
|
|
2095
2531
|
|
|
@@ -2113,8 +2549,8 @@ class ServicePage {
|
|
|
2113
2549
|
<span class="o-k">User</span><span class="o-mono o-sm">{{ c.user }}</span>
|
|
2114
2550
|
<span class="o-k">Password</span>
|
|
2115
2551
|
<span class="o-flex"><span class="o-mono o-sm" data-testid="svc-password">{{ revealed()?.password ?? '••••••••••••••••' }}</span>
|
|
2116
|
-
@if (d.
|
|
2117
|
-
@else {<span class="o-faint o-xs">
|
|
2552
|
+
@if (has(d, 'services.credentials.reveal')) {<button type="button" class="o-lnk" (click)="toggleReveal(d)" [disabled]="busy()" data-testid="svc-reveal">{{ revealed() ? 'Hide' : 'Reveal' }}</button>}
|
|
2553
|
+
@else {<span class="o-faint o-xs">Your role cannot reveal credentials in {{ d.environment }}</span>}
|
|
2118
2554
|
</span>
|
|
2119
2555
|
<span class="o-k">SSL mode</span><span class="o-mono o-sm">{{ c.sslMode }}</span>
|
|
2120
2556
|
</div>
|
|
@@ -2126,12 +2562,12 @@ class ServicePage {
|
|
|
2126
2562
|
</div>
|
|
2127
2563
|
<div class="o-col" style="gap:18px">
|
|
2128
2564
|
<div class="o-card">
|
|
2129
|
-
<div class="o-ch"><b>Attached apps</b>@if (d.
|
|
2565
|
+
<div class="o-ch"><b>Attached apps</b>@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn o-sm o-r" (click)="attachOpen.set(!attachOpen())" data-testid="svc-attach-open">Attach to app</button>}</div>
|
|
2130
2566
|
@for (a of d.attachments; track a.id) {
|
|
2131
2567
|
<div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
|
|
2132
2568
|
<span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>@if (a.ownUser) {<span class="o-faint o-xs o-mono" title="The app's own user">{{ a.user }}</span>}
|
|
2133
2569
|
<span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
|
|
2134
|
-
@if (d.
|
|
2570
|
+
@if (has(d, 'services.instance.configure')) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
|
|
2135
2571
|
</div>
|
|
2136
2572
|
} @empty {<div class="o-empty">Not attached. Attach it and the app gets {{ envVar() }} on its next release.</div>}
|
|
2137
2573
|
@if (attachOpen()) {
|
|
@@ -2144,15 +2580,16 @@ class ServicePage {
|
|
|
2144
2580
|
@if (d.engine === 'rabbitmq' && (d.vhosts ?? []).length) {<select class="o-in o-sm" style="width:auto" aria-label="Virtual host" [ngModel]="attVhost()" (ngModelChange)="attVhost.set($event)" data-testid="svc-att-vhost"><option value="/">/</option>@for (v of d.vhosts ?? []; track v) {<option [value]="v">{{ v }}</option>}</select>}
|
|
2145
2581
|
<label class="o-flex o-sm" style="gap:4px" title="A user of its own for this app, read-write, removed when it is detached"><input type="checkbox" [ngModel]="attOwn()" (ngModelChange)="attOwn.set($event)" data-testid="svc-att-own" /> Own user</label>
|
|
2146
2582
|
<span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
|
|
2147
|
-
<button type="button" class="o-btn o-sm o-pri o-r" (click)="attach(d)" [disabled]="busy() || !attApp()" data-testid="svc-att-go">Attach</button>
|
|
2583
|
+
<button type="button" class="o-btn o-sm o-pri o-r" (click)="attach(d)" [disabled]="busy() || !attApp() || attOtherRegion()" data-testid="svc-att-go">Attach</button>
|
|
2148
2584
|
</div>
|
|
2585
|
+
<div class="o-cf" [class.o-red]="attOtherRegion()" data-testid="svc-att-region">@if (attOtherRegion()) {<span>{{ attApp() }}'s {{ attEnv() }} environment runs in {{ attRegion() }} and {{ d.name }} in {{ region() }}: an app attaches only to a service in its own region. Create a service in {{ attRegion() }} for it.</span>} @else {<span>Apps attach only to services in their own region: {{ d.name }} runs in {{ region() }}.</span>}</div>
|
|
2149
2586
|
}
|
|
2150
|
-
<div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.
|
|
2587
|
+
<div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (has(d, 'services.instance.configure')) { · <button type="button" class="o-lnk" (click)="retire(d, r)" [disabled]="busy()">Retire the old password now</button>}}</div>
|
|
2151
2588
|
</div>
|
|
2152
2589
|
<div class="o-card">
|
|
2153
2590
|
<div class="o-ch"><b>Backups</b><span class="o-r">{{ d.engine === 'mysql' ? 'Hourly' : 'Daily' }} · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
|
|
2154
2591
|
@for (b of backups().slice(0, 3); track b.name) {
|
|
2155
|
-
<div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && d.
|
|
2592
|
+
<div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && has(d, 'services.data.import')) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore to new instance</button>}</div>
|
|
2156
2593
|
} @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
|
|
2157
2594
|
<div class="o-cf">{{ windowLine() }}</div>
|
|
2158
2595
|
</div>
|
|
@@ -2163,43 +2600,54 @@ class ServicePage {
|
|
|
2163
2600
|
<div class="o-g2" style="align-items:start;gap:18px">
|
|
2164
2601
|
@if (d.engine === 'nats') {
|
|
2165
2602
|
<div class="o-card" data-testid="svc-streams">
|
|
2166
|
-
<div class="o-ch"><b>Streams</b>@if (d.
|
|
2603
|
+
<div class="o-ch"><b>Streams</b>@if (has(d, 'services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-stream-add">+ Stream</button>}</div>
|
|
2167
2604
|
@if (addingDb()) {
|
|
2168
2605
|
<div class="o-row o-wrap" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" style="width:120px" placeholder="ORDERS" aria-label="Stream name" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" data-testid="svc-stream-name" /><input class="o-in o-sm o-mono" style="width:140px" placeholder="orders.>" aria-label="Subjects" [ngModel]="subjects()" (ngModelChange)="subjects.set($event)" data-testid="svc-stream-subjects" /><select class="o-in o-sm" style="width:auto" aria-label="Keep for" [ngModel]="maxAge()" (ngModelChange)="maxAge.set(+$event)">@for (k of streamAges; track k.h) {<option [ngValue]="k.h">{{ k.label }}</option>}</select><button type="button" class="o-btn o-sm o-dark" (click)="addStream(d)" [disabled]="busy() || !dbName()" data-testid="svc-stream-create">Create</button></div>
|
|
2169
2606
|
}
|
|
2170
2607
|
@for (st of d.streams; track st.name) {
|
|
2171
2608
|
<div class="o-row"><span class="o-mono o-b5">{{ st.name }}</span><span class="o-faint o-sm o-mono">{{ st.subjects.join(', ') }}</span><span class="o-r o-faint o-sm">{{ st.maxAgeHours ? 'kept ' + st.maxAgeHours + ' h' : 'kept until limits' }}</span>
|
|
2172
|
-
@if (d.
|
|
2609
|
+
@if (has(d, 'services.instance.configure')) {<button type="button" class="o-lnk o-faint" (click)="removeStream(d, st.name)" [attr.aria-label]="'Remove ' + st.name" [disabled]="busy()">×</button>}</div>
|
|
2173
2610
|
} @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
|
|
2174
2611
|
<div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
|
|
2175
2612
|
</div>
|
|
2176
2613
|
} @else if (d.engine === 'rabbitmq') {
|
|
2177
2614
|
<div class="o-card" data-testid="svc-vhosts">
|
|
2178
|
-
<div class="o-ch"><b>Virtual hosts</b>@if (d.
|
|
2615
|
+
<div class="o-ch"><b>Virtual hosts</b>@if (has(d, 'services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-vhost-add">+ Virtual host</button>}</div>
|
|
2179
2616
|
@if (addingDb()) {
|
|
2180
2617
|
<div class="o-row" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" placeholder="name" aria-label="Virtual host" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" (keydown.enter)="addVhost(d)" data-testid="svc-vhost-name" /><button type="button" class="o-btn o-sm o-dark" (click)="addVhost(d)" [disabled]="busy() || !dbName()" data-testid="svc-vhost-create">Create</button></div>
|
|
2181
2618
|
}
|
|
2182
2619
|
<div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
|
|
2183
2620
|
@for (v of d.vhosts ?? []; track v) {
|
|
2184
2621
|
<div class="o-row"><span class="o-mono o-b5">{{ v }}</span><span class="o-r o-faint o-sm">quorum queues</span>
|
|
2185
|
-
@if (d.
|
|
2622
|
+
@if (has(d, 'services.instance.configure')) {<button type="button" class="o-lnk o-faint" (click)="removeVhost(d, v)" [attr.aria-label]="'Remove ' + v" [disabled]="busy()">×</button>}</div>
|
|
2186
2623
|
}
|
|
2187
2624
|
<div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
|
|
2188
2625
|
</div>
|
|
2189
2626
|
} @else {
|
|
2190
2627
|
<div class="o-card">
|
|
2191
|
-
<div class="o-ch"><b>Databases</b>@if (d.
|
|
2628
|
+
<div class="o-ch"><b>Databases</b>@if (has(d, 'services.instance.configure') && engine()?.databases) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-db-add">+ Database</button>}</div>
|
|
2192
2629
|
@if (addingDb()) {
|
|
2193
2630
|
<div class="o-row" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" placeholder="name" aria-label="Database name" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" (keydown.enter)="addDb(d)" data-testid="svc-db-name" /><button type="button" class="o-btn o-sm o-dark" (click)="addDb(d)" [disabled]="busy() || !dbName()" data-testid="svc-db-create">Create</button></div>
|
|
2194
2631
|
}
|
|
2195
2632
|
@for (db of d.databases; track db.name; let first = $first) {
|
|
2196
2633
|
<div class="o-row"><span class="o-mono o-b5">{{ db.name }}</span><span class="o-faint o-sm">{{ first ? 'default' : '' }}</span><span class="o-r o-faint o-sm">{{ when(db.createdAt) }}</span>
|
|
2197
|
-
@if (d.
|
|
2634
|
+
@if (has(d, 'services.instance.configure') && !first) {<button type="button" class="o-lnk o-faint" (click)="removeDb(d, db.name)" [attr.aria-label]="'Remove ' + db.name" [disabled]="busy()">×</button>}</div>
|
|
2635
|
+
@if (engine()?.extensions?.length) {
|
|
2636
|
+
<div class="o-row o-wrap" style="padding-left:24px" [attr.data-testid]="'svc-ext-' + db.name">
|
|
2637
|
+
@for (x of db.extensions ?? []; track x.name) {<span class="o-chip o-mono" [class.o-grn]="x.created" [title]="x.created ? 'Created' : 'Being created'">{{ x.name }}@if (has(d, 'services.instance.configure')) { <button type="button" class="o-lnk o-faint" (click)="dropExt(d, db.name, x.name)" [attr.aria-label]="'Drop ' + x.name" [disabled]="busy()">×</button>}</span>}
|
|
2638
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {
|
|
2639
|
+
<select class="o-in o-xs" style="width:auto" [attr.aria-label]="'Add an extension to ' + db.name" [ngModel]="''" (ngModelChange)="addExt(d, db.name, $event)" [disabled]="busy()" [attr.data-testid]="'svc-ext-add-' + db.name">
|
|
2640
|
+
<option value="">+ Extension</option>
|
|
2641
|
+
@for (e of engine()!.extensions!; track e.name) {<option [value]="e.name" [title]="e.description">{{ e.name }}</option>}
|
|
2642
|
+
</select>
|
|
2643
|
+
}
|
|
2644
|
+
</div>
|
|
2645
|
+
}
|
|
2198
2646
|
} @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
|
|
2199
2647
|
</div>
|
|
2200
2648
|
}
|
|
2201
2649
|
<div class="o-card">
|
|
2202
|
-
<div class="o-ch"><b>Users</b>@if (d.
|
|
2650
|
+
<div class="o-ch"><b>Users</b>@if (has(d, 'services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="addingUser.set(!addingUser())" data-testid="svc-user-add">+ User</button>}</div>
|
|
2203
2651
|
@if (addingUser()) {
|
|
2204
2652
|
<div class="o-row" style="background:var(--o-bg)">
|
|
2205
2653
|
<input class="o-in o-sm o-mono" placeholder="name" aria-label="User name" [ngModel]="userName()" (ngModelChange)="userName.set($event)" data-testid="svc-user-name" />
|
|
@@ -2210,22 +2658,28 @@ class ServicePage {
|
|
|
2210
2658
|
@for (u of d.users ?? []; track u.name) {
|
|
2211
2659
|
<div class="o-row"><span class="o-mono o-b5">{{ u.name }}</span><span [class]="preset(u.preset)">{{ u.preset }}</span>@if (u.rotating) {<span class="o-chip o-blu">rotating</span>}
|
|
2212
2660
|
<span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
|
|
2213
|
-
@if (d
|
|
2661
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {
|
|
2662
|
+
<select class="o-in o-xs" style="width:auto" [attr.aria-label]="'Rotate ' + u.name + ' every'" [title]="rotationLine(u) || 'Rotate the password on a schedule'" [ngModel]="u.rotateEveryDays || 0" (ngModelChange)="setRotation(d, u.name, +$event)" [disabled]="busy()" [attr.data-testid]="'svc-user-rotation-' + u.name">
|
|
2663
|
+
@for (p of rotationPeriods; track p) {<option [ngValue]="p">{{ p ? 'Rotate every ' + p + ' days' : 'No scheduled rotation' }}</option>}
|
|
2664
|
+
</select>
|
|
2665
|
+
} @else if (u.rotateEveryDays) {<span class="o-faint o-xs">every {{ u.rotateEveryDays }} days</span>}
|
|
2666
|
+
@if (u.rotateEveryDays && u.nextRotation) {<span class="o-faint o-xs" [attr.data-testid]="'svc-user-next-' + u.name">{{ rotationLine(u) }}</span>}
|
|
2667
|
+
@if (has(d, 'services.instance.configure') && u.preset !== 'owner') {<button type="button" class="o-lnk o-faint" (click)="removeUser(d, u.name)" [attr.aria-label]="'Remove ' + u.name" [disabled]="busy()">×</button>}</div>
|
|
2214
2668
|
}
|
|
2215
|
-
<div class="o-cf">Per-app users: attach with “Own user” and each app gets its own least-privilege login, removed when it is detached
|
|
2669
|
+
<div class="o-cf">Per-app users: attach with “Own user” and each app gets its own least-privilege login, removed when it is detached. A scheduled rotation works as “Rotate credentials”: no connection fails, and the people who attached the apps are told.</div>
|
|
2216
2670
|
</div>
|
|
2217
2671
|
</div>
|
|
2218
2672
|
}
|
|
2219
2673
|
@case ('backups') {
|
|
2220
2674
|
<div class="o-split o-w340">
|
|
2221
2675
|
<div class="o-card">
|
|
2222
|
-
<div class="o-ch"><b>Backups</b>@if (d.
|
|
2676
|
+
<div class="o-ch"><b>Backups</b>@if (has(d, 'services.instance.configure') && !d.deletedAt) {<span class="o-r"><button type="button" class="o-btn o-sm" (click)="backupNow(d)" [disabled]="busy() || d.state !== 'Running'" data-testid="svc-backup-now">Back up now</button></span>}</div>
|
|
2223
2677
|
@for (b of backups(); track b.name) {
|
|
2224
2678
|
<div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
|
|
2225
2679
|
<span [class]="'o-dot ' + backupDot(b)"></span><span style="width:150px">{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint o-sm" style="width:80px">{{ backupNote(b) }}</span>
|
|
2226
2680
|
<span class="o-chip">{{ kind(b) }}</span>@if (b.volumes) {<span [class]="'o-chip ' + (b.volumes.phase === 'completed' ? 'o-grn' : b.volumes.phase === 'failed' ? 'o-red' : 'o-blu')" [title]="b.volumes.error || b.volumes.snapshots + ' volume snapshot(s)'" data-testid="svc-backup-volumes">{{ b.volumes.phase === 'completed' ? '+ queue data' : b.volumes.phase === 'failed' ? 'queue data failed' : 'snapshotting' }}</span>}<span class="o-faint o-sm">{{ keepLine(b, d.retentionDays) }}</span>
|
|
2227
|
-
@if (b.phase === 'completed' && d.
|
|
2228
|
-
@if (b.kind === 'on-demand' && d.
|
|
2681
|
+
@if (b.phase === 'completed' && has(d, 'services.data.import')) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore</button>}
|
|
2682
|
+
@if (b.kind === 'on-demand' && has(d, 'services.instance.configure') && b.phase !== 'running') {<button type="button" class="o-lnk o-faint" [class.o-r]="b.phase !== 'completed'" (click)="deleteBackup(d, b)" [attr.aria-label]="'Delete ' + b.name" [disabled]="busy()">×</button>}
|
|
2229
2683
|
</div>
|
|
2230
2684
|
} @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
|
|
2231
2685
|
<div class="o-cf">Stored encrypted in a separate failure domain.</div>
|
|
@@ -2236,7 +2690,7 @@ class ServicePage {
|
|
|
2236
2690
|
<b>Restore to a point in time</b>
|
|
2237
2691
|
<span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
|
|
2238
2692
|
<input class="o-in o-mono" type="datetime-local" step="1" aria-label="Point in time" [ngModel]="pitr()" (ngModelChange)="pitr.set($event)" data-testid="svc-pitr" />
|
|
2239
|
-
<button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!d.
|
|
2693
|
+
<button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!has(d, 'services.data.import') || !pitr() || !d.window?.from" data-testid="svc-pitr-go">Restore…</button>
|
|
2240
2694
|
</div>
|
|
2241
2695
|
} @else {
|
|
2242
2696
|
<div class="o-card o-cb o-col" style="gap:6px"><b>Restore a snapshot</b><span class="o-hint">{{ d.engineName }} restores from a daily {{ snapshotName(d) }}: pick one on the left. It always creates a new instance.</span>
|
|
@@ -2248,12 +2702,13 @@ class ServicePage {
|
|
|
2248
2702
|
@if (d.engine === 'mysql') {
|
|
2249
2703
|
<div class="o-fld"><label>Runs</label><input class="o-in" value="Every hour, on the hour" readonly aria-label="Runs" /><span class="o-hint">MySQL restores to any second up to the latest backup: at most an hour behind.</span></div>
|
|
2250
2704
|
} @else {
|
|
2251
|
-
<div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.
|
|
2705
|
+
<div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
|
|
2252
2706
|
}
|
|
2253
|
-
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.
|
|
2707
|
+
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.retentionDays" (ngModelChange)="save(d, { retentionDays: +$event }, 'Backups are now kept ' + $event + ' days.')">@for (k of keeps; track k) {<option [ngValue]="k">{{ k }} days</option>}</select></div>
|
|
2254
2708
|
</div>
|
|
2255
2709
|
</div>
|
|
2256
2710
|
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
2711
|
+
<o-svc-copies [d]="d" [projectKey]="key()" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
2257
2712
|
</div>
|
|
2258
2713
|
</div>
|
|
2259
2714
|
}
|
|
@@ -2271,33 +2726,35 @@ class ServicePage {
|
|
|
2271
2726
|
<div class="o-card">
|
|
2272
2727
|
<div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
|
|
2273
2728
|
<div class="o-cb o-flex o-wrap">
|
|
2274
|
-
<select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!d.
|
|
2729
|
+
<select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="newPlan()" (ngModelChange)="newPlan.set($event)" data-testid="svc-plan">
|
|
2275
2730
|
@for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
|
|
2276
2731
|
</select>
|
|
2277
|
-
<label class="o-flex o-sm" style="gap:6px">Disk <input class="o-in o-sm o-mono" style="width:80px" type="number" [min]="d.diskGb" [max]="catalog()?.maxDiskGb ?? 2000" [disabled]="!d.
|
|
2278
|
-
<button type="button" class="o-btn o-dark" (click)="resize(d)" [disabled]="busy() || !d.
|
|
2732
|
+
<label class="o-flex o-sm" style="gap:6px">Disk <input class="o-in o-sm o-mono" style="width:80px" type="number" [min]="d.diskGb" [max]="catalog()?.maxDiskGb ?? 2000" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="newDisk()" (ngModelChange)="newDisk.set(+$event)" data-testid="svc-disk" /> GB</label>
|
|
2733
|
+
<button type="button" class="o-btn o-dark" (click)="resize(d)" [disabled]="busy() || !has(d, 'services.instance.configure') || (newPlan() === d.plan && newDisk() === d.diskGb)" data-testid="svc-resize">Resize</button>
|
|
2279
2734
|
</div>
|
|
2280
2735
|
</div>
|
|
2736
|
+
<o-svc-storage [d]="d" [projectKey]="key()" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
2737
|
+
<o-svc-upgrade [d]="d" [projectKey]="key()" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
2281
2738
|
<div class="o-card" data-testid="svc-ha">
|
|
2282
2739
|
<div class="o-ch"><b>High availability</b><span class="o-r">{{ d.readyNodes }} of {{ d.nodes }} node{{ d.nodes === 1 ? '' : 's' }} ready</span></div>
|
|
2283
2740
|
@switch (engine()?.ha) {
|
|
2284
2741
|
@case ('standby') {
|
|
2285
|
-
<div class="o-row"><span style="flex:1">Standby node with automatic failover@if (d.engine === 'mysql') {<span class="o-faint o-sm"> · two semi-synchronous replicas (MOCO runs 1, 3 or 5)</span>}</span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!d.
|
|
2742
|
+
<div class="o-row"><span style="flex:1">Standby node with automatic failover@if (d.engine === 'mysql') {<span class="o-faint o-sm"> · two semi-synchronous replicas (MOCO runs 1, 3 or 5)</span>}</span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="topology(d, !d.standby, d.standby ? 0 : d.replicas)" aria-label="Standby" data-testid="svc-standby"></button></div>
|
|
2286
2743
|
<div class="o-row"><span style="flex:1">Read replicas@if (d.readHost) {<span class="o-faint o-sm o-mono"> · {{ d.readHost }}</span>}</span><span class="o-faint o-sm" data-testid="svc-replicas">{{ d.replicas }}</span>
|
|
2287
|
-
@if (d.
|
|
2744
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2288
2745
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2289
2746
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby || d.engine === 'mysql', d.replicas + step(d))" [disabled]="busy() || d.replicas + step(d) > (engine()?.maxReplicas ?? 0)" data-testid="svc-replica-add">+ Replica</button>
|
|
2290
2747
|
}</div>
|
|
2291
2748
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2292
2749
|
}
|
|
2293
2750
|
@case ('cluster') {
|
|
2294
|
-
<div class="o-row"><span style="flex:1">3-node cluster<span class="o-faint o-sm"> · {{ d.engine === 'nats' ? 'streams replicated on three servers' : 'quorum queues on three nodes' }}</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!d.
|
|
2751
|
+
<div class="o-row"><span style="flex:1">3-node cluster<span class="o-faint o-sm"> · {{ d.engine === 'nats' ? 'streams replicated on three servers' : 'quorum queues on three nodes' }}</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="topology(d, !d.standby, 0)" aria-label="3-node cluster" data-testid="svc-standby"></button></div>
|
|
2295
2752
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2296
2753
|
}
|
|
2297
2754
|
@case ('replica') {
|
|
2298
|
-
<div class="o-row"><span style="flex:1">Replica with automatic failover<span class="o-faint o-sm"> · three Sentinels promote it if the primary fails</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!d.
|
|
2755
|
+
<div class="o-row"><span style="flex:1">Replica with automatic failover<span class="o-faint o-sm"> · three Sentinels promote it if the primary fails</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="topology(d, !d.standby, 0)" aria-label="Replica with failover" data-testid="svc-standby"></button></div>
|
|
2299
2756
|
<div class="o-row"><span style="flex:1">Second replica@if (d.sentinel; as sn) {<span class="o-faint o-sm o-mono"> · Sentinels {{ sn.hosts.length }} × :{{ sn.port }}, master {{ sn.master }}</span>}</span><span class="o-faint o-sm" data-testid="svc-replicas">{{ d.replicas }}</span>
|
|
2300
|
-
@if (d.
|
|
2757
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2301
2758
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - 1)" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2302
2759
|
<button type="button" class="o-btn o-xs" (click)="topology(d, true, d.replicas + 1)" [disabled]="busy() || d.replicas + 1 > (engine()?.maxReplicas ?? 0)" data-testid="svc-replica-add">+ Replica</button>
|
|
2303
2760
|
}</div>
|
|
@@ -2312,11 +2769,11 @@ class ServicePage {
|
|
|
2312
2769
|
<div class="o-card">
|
|
2313
2770
|
<div class="o-ch"><b>Networking</b></div>
|
|
2314
2771
|
<div class="o-row"><span style="flex:1">Private network <span class="o-faint o-sm">· always on, reachable only from attached apps</span></span><span class="o-chip o-grn">On</span></div>
|
|
2315
|
-
<div class="o-row"><span style="flex:1">Public endpoint with trusted sources</span><button type="button" class="o-tog" [class.o-on]="d.publicEnabled" [disabled]="!d.
|
|
2772
|
+
<div class="o-row"><span style="flex:1">Public endpoint with trusted sources</span><button type="button" class="o-tog" [class.o-on]="d.publicEnabled" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="save(d, { publicEnabled: !d.publicEnabled }, d.publicEnabled ? 'The public endpoint is off.' : 'The public endpoint is on for the trusted sources below.')" aria-label="Public endpoint" data-testid="svc-public"></button></div>
|
|
2316
2773
|
@if (d.publicEnabled) {
|
|
2317
2774
|
<div class="o-row o-wrap" style="background:var(--o-bg)">
|
|
2318
|
-
@for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (d.
|
|
2319
|
-
@if (d.
|
|
2775
|
+
@for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (has(d, 'services.instance.configure')) { <button type="button" class="o-lnk o-faint" (click)="dropCidr(d, c)" [attr.aria-label]="'Remove ' + c">×</button>}</span>}
|
|
2776
|
+
@if (has(d, 'services.instance.configure')) {<input class="o-in o-sm o-mono" style="width:180px" placeholder="+ IP or CIDR" aria-label="Add a trusted source" [ngModel]="cidr()" (ngModelChange)="cidr.set($event)" (keydown.enter)="addCidr(d)" data-testid="svc-cidr" />}
|
|
2320
2777
|
<span class="o-faint o-sm o-r">TLS required</span>
|
|
2321
2778
|
</div>
|
|
2322
2779
|
}
|
|
@@ -2324,22 +2781,22 @@ class ServicePage {
|
|
|
2324
2781
|
<div class="o-card">
|
|
2325
2782
|
<div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
|
|
2326
2783
|
<div class="o-cb o-flex o-wrap">
|
|
2327
|
-
<select class="o-in" style="width:auto" aria-label="Day" [disabled]="!d.
|
|
2328
|
-
<select class="o-in" style="width:auto" aria-label="Hours" [disabled]="!d.
|
|
2784
|
+
<select class="o-in" style="width:auto" aria-label="Day" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.maintenanceDay" (ngModelChange)="save(d, { maintenanceDay: $event }, 'Maintenance window saved.')">@for (x of days; track x.id) {<option [value]="x.id">{{ x.label }}</option>}</select>
|
|
2785
|
+
<select class="o-in" style="width:auto" aria-label="Hours" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.maintenanceHour" (ngModelChange)="save(d, { maintenanceHour: +$event }, 'Maintenance window saved.')">@for (h of hours; track h) {<option [ngValue]="h">{{ windowHours(h) }}</option>}</select>
|
|
2329
2786
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
2330
2787
|
</div>
|
|
2331
2788
|
</div>
|
|
2332
2789
|
<div class="o-card" data-testid="svc-params"><div class="o-ch"><b>Engine parameters</b><span class="o-r">validated subset</span></div><div class="o-tbl"><div class="o-tbody">
|
|
2333
2790
|
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2334
2791
|
<div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
|
|
2335
|
-
<div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [placeholder]="p.default" [disabled]="!d.
|
|
2792
|
+
<div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [placeholder]="p.default" [disabled]="!has(d, 'services.instance.configure')" [attr.aria-label]="p.name" [ngModel]="paramDraft()[p.name] ?? p.value ?? ''" (ngModelChange)="setParam(p, $event)" [attr.data-testid]="'svc-param-' + p.name" /></div>
|
|
2336
2793
|
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2337
2794
|
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2338
2795
|
</div>}
|
|
2339
2796
|
</div></div>
|
|
2340
|
-
@if (d.
|
|
2797
|
+
@if (has(d, 'services.instance.configure')) {<div class="o-cf"><span class="o-faint o-sm">{{ paramNote(d) }}</span><button type="button" class="o-btn o-sm o-dark o-r" (click)="saveParams(d)" [disabled]="busy() || !paramsChanged()" data-testid="svc-params-save">Save parameters</button></div>}
|
|
2341
2798
|
</div>
|
|
2342
|
-
@if (d.
|
|
2799
|
+
@if (has(d, 'services.instance.delete') && !d.deletedAt) {
|
|
2343
2800
|
<div class="o-card" style="border-color:var(--o-bad-line)"><div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>Delete {{ d.name }}</b><span class="o-hint">Typed confirmation. Undo within 24 hours; optional final backup kept 7 days. Attached apps lose {{ envVar() }}.</span></div><button type="button" class="o-btn o-sm o-red" (click)="dialog.set({ kind: 'delete', typed: '', finalBackup: true })" data-testid="svc-delete">Delete</button></div></div>
|
|
2344
2801
|
}
|
|
2345
2802
|
</div>
|
|
@@ -2384,13 +2841,13 @@ class ServicePage {
|
|
|
2384
2841
|
} @else {
|
|
2385
2842
|
<div class="o-page">@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>} @else {<div class="o-empty">Loading…</div>}</div>
|
|
2386
2843
|
}
|
|
2387
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ServiceMetricsTab, selector: "o-svc-metrics", inputs: ["readings", "slowLines", "engine", "projectKey", "service", "dashboard"] }, { kind: "component", type: ServiceLogsTab, selector: "o-svc-logs", inputs: ["projectKey", "id", "service", "environment", "debugLogs"] }, { kind: "component", type: ServiceConsoleTab, selector: "o-svc-console", inputs: ["d", "projectKey", "kind"] }, { kind: "component", type: ServiceDumpsCard, selector: "o-svc-dumps", inputs: ["d", "projectKey", "dumps"], outputs: ["done", "failed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2844
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ServiceMetricsTab, selector: "o-svc-metrics", inputs: ["readings", "slowLines", "engine", "projectKey", "service", "dashboard"] }, { kind: "component", type: ServiceLogsTab, selector: "o-svc-logs", inputs: ["projectKey", "id", "service", "environment", "debugLogs"] }, { kind: "component", type: ServiceConsoleTab, selector: "o-svc-console", inputs: ["d", "projectKey", "kind"] }, { kind: "component", type: ServiceDumpsCard, selector: "o-svc-dumps", inputs: ["d", "projectKey", "dumps"], outputs: ["done", "failed"] }, { kind: "component", type: ServiceUpgradeCard, selector: "o-svc-upgrade" }, { kind: "component", type: ServiceCopiesCard, selector: "o-svc-copies" }, { kind: "component", type: ServiceStorageCard, selector: "o-svc-storage" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2388
2845
|
}
|
|
2389
2846
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicePage, decorators: [{
|
|
2390
2847
|
type: Component,
|
|
2391
2848
|
args: [{
|
|
2392
2849
|
selector: 'o-service',
|
|
2393
|
-
imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab, ServiceConsoleTab, ServiceDumpsCard],
|
|
2850
|
+
imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab, ServiceConsoleTab, ServiceDumpsCard, ServiceUpgradeCard, ServiceCopiesCard, ServiceStorageCard],
|
|
2394
2851
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
2395
2852
|
template: `
|
|
2396
2853
|
@if (d(); as d) {
|
|
@@ -2402,8 +2859,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2402
2859
|
</div>
|
|
2403
2860
|
<div class="o-acts">
|
|
2404
2861
|
@if (d.management && !d.deletedAt) {<button type="button" class="o-btn" (click)="openManagement(d)" [disabled]="busy() || d.state !== 'Running' && d.state !== 'Updating' && d.state !== 'Degraded'" [title]="d.management === 'read' ? 'Read-only for your role' : 'As the instance administrator'" data-testid="svc-management">Open management UI@if (d.management === 'read') {<span class="o-faint o-xs"> · read-only</span>}</button>}
|
|
2405
|
-
@if (d.
|
|
2406
|
-
@if (d.
|
|
2862
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn" (click)="askRotate(d)" [disabled]="busy() || !d.users?.length" data-testid="svc-rotate">Rotate credentials</button>}
|
|
2863
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn" (click)="setTab('settings')">Resize</button>}
|
|
2407
2864
|
<a class="o-btn" [routerLink]="host.link('dashboards')">Dashboard</a>
|
|
2408
2865
|
</div>
|
|
2409
2866
|
</div>
|
|
@@ -2414,13 +2871,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2414
2871
|
@if (d.deletedAt) {
|
|
2415
2872
|
<div class="o-card o-cb o-flex o-wrap" style="border-color:var(--o-bad-line)" data-testid="svc-deleted">
|
|
2416
2873
|
<div class="o-col" style="gap:2px;flex:1"><b>{{ d.name }} is scheduled for deletion</b><span class="o-hint">Purged {{ when(d.purgeAt) }}. Until then you can undo; attached apps were detached.@if (d.finalBackup) { A final backup is kept for 7 days.}</span></div>
|
|
2417
|
-
@if (d.
|
|
2874
|
+
@if (has(d, 'services.instance.delete')) {<button type="button" class="o-btn" (click)="undelete(d)" [disabled]="busy()" data-testid="svc-undelete">Undo delete</button>}
|
|
2418
2875
|
</div>
|
|
2419
2876
|
}
|
|
2420
2877
|
@if (d.restoreOf; as src) {
|
|
2421
2878
|
<div class="o-card o-cb o-flex o-wrap" data-testid="svc-restored">
|
|
2422
2879
|
<div class="o-col" style="gap:2px;flex:1"><b>Restored from <a [routerLink]="host.link('services/' + src.name)">{{ src.name }}</a></b><span class="o-hint">@if (d.restorePoint) {To {{ when(d.restorePoint) }}.} @else if (d.restoreBackup) {From backup {{ d.restoreBackup }}.} {{ src.name }} is untouched.@if (src.attached.length) { Its apps: {{ src.attached.join(', ') }}.}</span></div>
|
|
2423
|
-
@if (d.
|
|
2880
|
+
@if (has(d, 'services.instance.configure') && src.attached.length && d.state === 'Running') {<button type="button" class="o-btn" (click)="swap(d, src.name)" [disabled]="busy()" data-testid="svc-swap">Move {{ src.name }}'s apps here</button>}
|
|
2424
2881
|
</div>
|
|
2425
2882
|
}
|
|
2426
2883
|
|
|
@@ -2444,8 +2901,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2444
2901
|
<span class="o-k">User</span><span class="o-mono o-sm">{{ c.user }}</span>
|
|
2445
2902
|
<span class="o-k">Password</span>
|
|
2446
2903
|
<span class="o-flex"><span class="o-mono o-sm" data-testid="svc-password">{{ revealed()?.password ?? '••••••••••••••••' }}</span>
|
|
2447
|
-
@if (d.
|
|
2448
|
-
@else {<span class="o-faint o-xs">
|
|
2904
|
+
@if (has(d, 'services.credentials.reveal')) {<button type="button" class="o-lnk" (click)="toggleReveal(d)" [disabled]="busy()" data-testid="svc-reveal">{{ revealed() ? 'Hide' : 'Reveal' }}</button>}
|
|
2905
|
+
@else {<span class="o-faint o-xs">Your role cannot reveal credentials in {{ d.environment }}</span>}
|
|
2449
2906
|
</span>
|
|
2450
2907
|
<span class="o-k">SSL mode</span><span class="o-mono o-sm">{{ c.sslMode }}</span>
|
|
2451
2908
|
</div>
|
|
@@ -2457,12 +2914,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2457
2914
|
</div>
|
|
2458
2915
|
<div class="o-col" style="gap:18px">
|
|
2459
2916
|
<div class="o-card">
|
|
2460
|
-
<div class="o-ch"><b>Attached apps</b>@if (d.
|
|
2917
|
+
<div class="o-ch"><b>Attached apps</b>@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn o-sm o-r" (click)="attachOpen.set(!attachOpen())" data-testid="svc-attach-open">Attach to app</button>}</div>
|
|
2461
2918
|
@for (a of d.attachments; track a.id) {
|
|
2462
2919
|
<div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
|
|
2463
2920
|
<span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>@if (a.ownUser) {<span class="o-faint o-xs o-mono" title="The app's own user">{{ a.user }}</span>}
|
|
2464
2921
|
<span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
|
|
2465
|
-
@if (d.
|
|
2922
|
+
@if (has(d, 'services.instance.configure')) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
|
|
2466
2923
|
</div>
|
|
2467
2924
|
} @empty {<div class="o-empty">Not attached. Attach it and the app gets {{ envVar() }} on its next release.</div>}
|
|
2468
2925
|
@if (attachOpen()) {
|
|
@@ -2475,15 +2932,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2475
2932
|
@if (d.engine === 'rabbitmq' && (d.vhosts ?? []).length) {<select class="o-in o-sm" style="width:auto" aria-label="Virtual host" [ngModel]="attVhost()" (ngModelChange)="attVhost.set($event)" data-testid="svc-att-vhost"><option value="/">/</option>@for (v of d.vhosts ?? []; track v) {<option [value]="v">{{ v }}</option>}</select>}
|
|
2476
2933
|
<label class="o-flex o-sm" style="gap:4px" title="A user of its own for this app, read-write, removed when it is detached"><input type="checkbox" [ngModel]="attOwn()" (ngModelChange)="attOwn.set($event)" data-testid="svc-att-own" /> Own user</label>
|
|
2477
2934
|
<span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
|
|
2478
|
-
<button type="button" class="o-btn o-sm o-pri o-r" (click)="attach(d)" [disabled]="busy() || !attApp()" data-testid="svc-att-go">Attach</button>
|
|
2935
|
+
<button type="button" class="o-btn o-sm o-pri o-r" (click)="attach(d)" [disabled]="busy() || !attApp() || attOtherRegion()" data-testid="svc-att-go">Attach</button>
|
|
2479
2936
|
</div>
|
|
2937
|
+
<div class="o-cf" [class.o-red]="attOtherRegion()" data-testid="svc-att-region">@if (attOtherRegion()) {<span>{{ attApp() }}'s {{ attEnv() }} environment runs in {{ attRegion() }} and {{ d.name }} in {{ region() }}: an app attaches only to a service in its own region. Create a service in {{ attRegion() }} for it.</span>} @else {<span>Apps attach only to services in their own region: {{ d.name }} runs in {{ region() }}.</span>}</div>
|
|
2480
2938
|
}
|
|
2481
|
-
<div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.
|
|
2939
|
+
<div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (has(d, 'services.instance.configure')) { · <button type="button" class="o-lnk" (click)="retire(d, r)" [disabled]="busy()">Retire the old password now</button>}}</div>
|
|
2482
2940
|
</div>
|
|
2483
2941
|
<div class="o-card">
|
|
2484
2942
|
<div class="o-ch"><b>Backups</b><span class="o-r">{{ d.engine === 'mysql' ? 'Hourly' : 'Daily' }} · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
|
|
2485
2943
|
@for (b of backups().slice(0, 3); track b.name) {
|
|
2486
|
-
<div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && d.
|
|
2944
|
+
<div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && has(d, 'services.data.import')) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore to new instance</button>}</div>
|
|
2487
2945
|
} @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
|
|
2488
2946
|
<div class="o-cf">{{ windowLine() }}</div>
|
|
2489
2947
|
</div>
|
|
@@ -2494,43 +2952,54 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2494
2952
|
<div class="o-g2" style="align-items:start;gap:18px">
|
|
2495
2953
|
@if (d.engine === 'nats') {
|
|
2496
2954
|
<div class="o-card" data-testid="svc-streams">
|
|
2497
|
-
<div class="o-ch"><b>Streams</b>@if (d.
|
|
2955
|
+
<div class="o-ch"><b>Streams</b>@if (has(d, 'services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-stream-add">+ Stream</button>}</div>
|
|
2498
2956
|
@if (addingDb()) {
|
|
2499
2957
|
<div class="o-row o-wrap" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" style="width:120px" placeholder="ORDERS" aria-label="Stream name" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" data-testid="svc-stream-name" /><input class="o-in o-sm o-mono" style="width:140px" placeholder="orders.>" aria-label="Subjects" [ngModel]="subjects()" (ngModelChange)="subjects.set($event)" data-testid="svc-stream-subjects" /><select class="o-in o-sm" style="width:auto" aria-label="Keep for" [ngModel]="maxAge()" (ngModelChange)="maxAge.set(+$event)">@for (k of streamAges; track k.h) {<option [ngValue]="k.h">{{ k.label }}</option>}</select><button type="button" class="o-btn o-sm o-dark" (click)="addStream(d)" [disabled]="busy() || !dbName()" data-testid="svc-stream-create">Create</button></div>
|
|
2500
2958
|
}
|
|
2501
2959
|
@for (st of d.streams; track st.name) {
|
|
2502
2960
|
<div class="o-row"><span class="o-mono o-b5">{{ st.name }}</span><span class="o-faint o-sm o-mono">{{ st.subjects.join(', ') }}</span><span class="o-r o-faint o-sm">{{ st.maxAgeHours ? 'kept ' + st.maxAgeHours + ' h' : 'kept until limits' }}</span>
|
|
2503
|
-
@if (d.
|
|
2961
|
+
@if (has(d, 'services.instance.configure')) {<button type="button" class="o-lnk o-faint" (click)="removeStream(d, st.name)" [attr.aria-label]="'Remove ' + st.name" [disabled]="busy()">×</button>}</div>
|
|
2504
2962
|
} @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
|
|
2505
2963
|
<div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
|
|
2506
2964
|
</div>
|
|
2507
2965
|
} @else if (d.engine === 'rabbitmq') {
|
|
2508
2966
|
<div class="o-card" data-testid="svc-vhosts">
|
|
2509
|
-
<div class="o-ch"><b>Virtual hosts</b>@if (d.
|
|
2967
|
+
<div class="o-ch"><b>Virtual hosts</b>@if (has(d, 'services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-vhost-add">+ Virtual host</button>}</div>
|
|
2510
2968
|
@if (addingDb()) {
|
|
2511
2969
|
<div class="o-row" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" placeholder="name" aria-label="Virtual host" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" (keydown.enter)="addVhost(d)" data-testid="svc-vhost-name" /><button type="button" class="o-btn o-sm o-dark" (click)="addVhost(d)" [disabled]="busy() || !dbName()" data-testid="svc-vhost-create">Create</button></div>
|
|
2512
2970
|
}
|
|
2513
2971
|
<div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
|
|
2514
2972
|
@for (v of d.vhosts ?? []; track v) {
|
|
2515
2973
|
<div class="o-row"><span class="o-mono o-b5">{{ v }}</span><span class="o-r o-faint o-sm">quorum queues</span>
|
|
2516
|
-
@if (d.
|
|
2974
|
+
@if (has(d, 'services.instance.configure')) {<button type="button" class="o-lnk o-faint" (click)="removeVhost(d, v)" [attr.aria-label]="'Remove ' + v" [disabled]="busy()">×</button>}</div>
|
|
2517
2975
|
}
|
|
2518
2976
|
<div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
|
|
2519
2977
|
</div>
|
|
2520
2978
|
} @else {
|
|
2521
2979
|
<div class="o-card">
|
|
2522
|
-
<div class="o-ch"><b>Databases</b>@if (d.
|
|
2980
|
+
<div class="o-ch"><b>Databases</b>@if (has(d, 'services.instance.configure') && engine()?.databases) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-db-add">+ Database</button>}</div>
|
|
2523
2981
|
@if (addingDb()) {
|
|
2524
2982
|
<div class="o-row" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" placeholder="name" aria-label="Database name" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" (keydown.enter)="addDb(d)" data-testid="svc-db-name" /><button type="button" class="o-btn o-sm o-dark" (click)="addDb(d)" [disabled]="busy() || !dbName()" data-testid="svc-db-create">Create</button></div>
|
|
2525
2983
|
}
|
|
2526
2984
|
@for (db of d.databases; track db.name; let first = $first) {
|
|
2527
2985
|
<div class="o-row"><span class="o-mono o-b5">{{ db.name }}</span><span class="o-faint o-sm">{{ first ? 'default' : '' }}</span><span class="o-r o-faint o-sm">{{ when(db.createdAt) }}</span>
|
|
2528
|
-
@if (d.
|
|
2986
|
+
@if (has(d, 'services.instance.configure') && !first) {<button type="button" class="o-lnk o-faint" (click)="removeDb(d, db.name)" [attr.aria-label]="'Remove ' + db.name" [disabled]="busy()">×</button>}</div>
|
|
2987
|
+
@if (engine()?.extensions?.length) {
|
|
2988
|
+
<div class="o-row o-wrap" style="padding-left:24px" [attr.data-testid]="'svc-ext-' + db.name">
|
|
2989
|
+
@for (x of db.extensions ?? []; track x.name) {<span class="o-chip o-mono" [class.o-grn]="x.created" [title]="x.created ? 'Created' : 'Being created'">{{ x.name }}@if (has(d, 'services.instance.configure')) { <button type="button" class="o-lnk o-faint" (click)="dropExt(d, db.name, x.name)" [attr.aria-label]="'Drop ' + x.name" [disabled]="busy()">×</button>}</span>}
|
|
2990
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {
|
|
2991
|
+
<select class="o-in o-xs" style="width:auto" [attr.aria-label]="'Add an extension to ' + db.name" [ngModel]="''" (ngModelChange)="addExt(d, db.name, $event)" [disabled]="busy()" [attr.data-testid]="'svc-ext-add-' + db.name">
|
|
2992
|
+
<option value="">+ Extension</option>
|
|
2993
|
+
@for (e of engine()!.extensions!; track e.name) {<option [value]="e.name" [title]="e.description">{{ e.name }}</option>}
|
|
2994
|
+
</select>
|
|
2995
|
+
}
|
|
2996
|
+
</div>
|
|
2997
|
+
}
|
|
2529
2998
|
} @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
|
|
2530
2999
|
</div>
|
|
2531
3000
|
}
|
|
2532
3001
|
<div class="o-card">
|
|
2533
|
-
<div class="o-ch"><b>Users</b>@if (d.
|
|
3002
|
+
<div class="o-ch"><b>Users</b>@if (has(d, 'services.instance.configure')) {<button type="button" class="o-btn o-sm o-r" (click)="addingUser.set(!addingUser())" data-testid="svc-user-add">+ User</button>}</div>
|
|
2534
3003
|
@if (addingUser()) {
|
|
2535
3004
|
<div class="o-row" style="background:var(--o-bg)">
|
|
2536
3005
|
<input class="o-in o-sm o-mono" placeholder="name" aria-label="User name" [ngModel]="userName()" (ngModelChange)="userName.set($event)" data-testid="svc-user-name" />
|
|
@@ -2541,22 +3010,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2541
3010
|
@for (u of d.users ?? []; track u.name) {
|
|
2542
3011
|
<div class="o-row"><span class="o-mono o-b5">{{ u.name }}</span><span [class]="preset(u.preset)">{{ u.preset }}</span>@if (u.rotating) {<span class="o-chip o-blu">rotating</span>}
|
|
2543
3012
|
<span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
|
|
2544
|
-
@if (d
|
|
3013
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {
|
|
3014
|
+
<select class="o-in o-xs" style="width:auto" [attr.aria-label]="'Rotate ' + u.name + ' every'" [title]="rotationLine(u) || 'Rotate the password on a schedule'" [ngModel]="u.rotateEveryDays || 0" (ngModelChange)="setRotation(d, u.name, +$event)" [disabled]="busy()" [attr.data-testid]="'svc-user-rotation-' + u.name">
|
|
3015
|
+
@for (p of rotationPeriods; track p) {<option [ngValue]="p">{{ p ? 'Rotate every ' + p + ' days' : 'No scheduled rotation' }}</option>}
|
|
3016
|
+
</select>
|
|
3017
|
+
} @else if (u.rotateEveryDays) {<span class="o-faint o-xs">every {{ u.rotateEveryDays }} days</span>}
|
|
3018
|
+
@if (u.rotateEveryDays && u.nextRotation) {<span class="o-faint o-xs" [attr.data-testid]="'svc-user-next-' + u.name">{{ rotationLine(u) }}</span>}
|
|
3019
|
+
@if (has(d, 'services.instance.configure') && u.preset !== 'owner') {<button type="button" class="o-lnk o-faint" (click)="removeUser(d, u.name)" [attr.aria-label]="'Remove ' + u.name" [disabled]="busy()">×</button>}</div>
|
|
2545
3020
|
}
|
|
2546
|
-
<div class="o-cf">Per-app users: attach with “Own user” and each app gets its own least-privilege login, removed when it is detached
|
|
3021
|
+
<div class="o-cf">Per-app users: attach with “Own user” and each app gets its own least-privilege login, removed when it is detached. A scheduled rotation works as “Rotate credentials”: no connection fails, and the people who attached the apps are told.</div>
|
|
2547
3022
|
</div>
|
|
2548
3023
|
</div>
|
|
2549
3024
|
}
|
|
2550
3025
|
@case ('backups') {
|
|
2551
3026
|
<div class="o-split o-w340">
|
|
2552
3027
|
<div class="o-card">
|
|
2553
|
-
<div class="o-ch"><b>Backups</b>@if (d.
|
|
3028
|
+
<div class="o-ch"><b>Backups</b>@if (has(d, 'services.instance.configure') && !d.deletedAt) {<span class="o-r"><button type="button" class="o-btn o-sm" (click)="backupNow(d)" [disabled]="busy() || d.state !== 'Running'" data-testid="svc-backup-now">Back up now</button></span>}</div>
|
|
2554
3029
|
@for (b of backups(); track b.name) {
|
|
2555
3030
|
<div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
|
|
2556
3031
|
<span [class]="'o-dot ' + backupDot(b)"></span><span style="width:150px">{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint o-sm" style="width:80px">{{ backupNote(b) }}</span>
|
|
2557
3032
|
<span class="o-chip">{{ kind(b) }}</span>@if (b.volumes) {<span [class]="'o-chip ' + (b.volumes.phase === 'completed' ? 'o-grn' : b.volumes.phase === 'failed' ? 'o-red' : 'o-blu')" [title]="b.volumes.error || b.volumes.snapshots + ' volume snapshot(s)'" data-testid="svc-backup-volumes">{{ b.volumes.phase === 'completed' ? '+ queue data' : b.volumes.phase === 'failed' ? 'queue data failed' : 'snapshotting' }}</span>}<span class="o-faint o-sm">{{ keepLine(b, d.retentionDays) }}</span>
|
|
2558
|
-
@if (b.phase === 'completed' && d.
|
|
2559
|
-
@if (b.kind === 'on-demand' && d.
|
|
3033
|
+
@if (b.phase === 'completed' && has(d, 'services.data.import')) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore</button>}
|
|
3034
|
+
@if (b.kind === 'on-demand' && has(d, 'services.instance.configure') && b.phase !== 'running') {<button type="button" class="o-lnk o-faint" [class.o-r]="b.phase !== 'completed'" (click)="deleteBackup(d, b)" [attr.aria-label]="'Delete ' + b.name" [disabled]="busy()">×</button>}
|
|
2560
3035
|
</div>
|
|
2561
3036
|
} @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
|
|
2562
3037
|
<div class="o-cf">Stored encrypted in a separate failure domain.</div>
|
|
@@ -2567,7 +3042,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2567
3042
|
<b>Restore to a point in time</b>
|
|
2568
3043
|
<span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
|
|
2569
3044
|
<input class="o-in o-mono" type="datetime-local" step="1" aria-label="Point in time" [ngModel]="pitr()" (ngModelChange)="pitr.set($event)" data-testid="svc-pitr" />
|
|
2570
|
-
<button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!d.
|
|
3045
|
+
<button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!has(d, 'services.data.import') || !pitr() || !d.window?.from" data-testid="svc-pitr-go">Restore…</button>
|
|
2571
3046
|
</div>
|
|
2572
3047
|
} @else {
|
|
2573
3048
|
<div class="o-card o-cb o-col" style="gap:6px"><b>Restore a snapshot</b><span class="o-hint">{{ d.engineName }} restores from a daily {{ snapshotName(d) }}: pick one on the left. It always creates a new instance.</span>
|
|
@@ -2579,12 +3054,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2579
3054
|
@if (d.engine === 'mysql') {
|
|
2580
3055
|
<div class="o-fld"><label>Runs</label><input class="o-in" value="Every hour, on the hour" readonly aria-label="Runs" /><span class="o-hint">MySQL restores to any second up to the latest backup: at most an hour behind.</span></div>
|
|
2581
3056
|
} @else {
|
|
2582
|
-
<div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.
|
|
3057
|
+
<div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
|
|
2583
3058
|
}
|
|
2584
|
-
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.
|
|
3059
|
+
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.retentionDays" (ngModelChange)="save(d, { retentionDays: +$event }, 'Backups are now kept ' + $event + ' days.')">@for (k of keeps; track k) {<option [ngValue]="k">{{ k }} days</option>}</select></div>
|
|
2585
3060
|
</div>
|
|
2586
3061
|
</div>
|
|
2587
3062
|
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
3063
|
+
<o-svc-copies [d]="d" [projectKey]="key()" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
2588
3064
|
</div>
|
|
2589
3065
|
</div>
|
|
2590
3066
|
}
|
|
@@ -2602,33 +3078,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2602
3078
|
<div class="o-card">
|
|
2603
3079
|
<div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
|
|
2604
3080
|
<div class="o-cb o-flex o-wrap">
|
|
2605
|
-
<select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!d.
|
|
3081
|
+
<select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="newPlan()" (ngModelChange)="newPlan.set($event)" data-testid="svc-plan">
|
|
2606
3082
|
@for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
|
|
2607
3083
|
</select>
|
|
2608
|
-
<label class="o-flex o-sm" style="gap:6px">Disk <input class="o-in o-sm o-mono" style="width:80px" type="number" [min]="d.diskGb" [max]="catalog()?.maxDiskGb ?? 2000" [disabled]="!d.
|
|
2609
|
-
<button type="button" class="o-btn o-dark" (click)="resize(d)" [disabled]="busy() || !d.
|
|
3084
|
+
<label class="o-flex o-sm" style="gap:6px">Disk <input class="o-in o-sm o-mono" style="width:80px" type="number" [min]="d.diskGb" [max]="catalog()?.maxDiskGb ?? 2000" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="newDisk()" (ngModelChange)="newDisk.set(+$event)" data-testid="svc-disk" /> GB</label>
|
|
3085
|
+
<button type="button" class="o-btn o-dark" (click)="resize(d)" [disabled]="busy() || !has(d, 'services.instance.configure') || (newPlan() === d.plan && newDisk() === d.diskGb)" data-testid="svc-resize">Resize</button>
|
|
2610
3086
|
</div>
|
|
2611
3087
|
</div>
|
|
3088
|
+
<o-svc-storage [d]="d" [projectKey]="key()" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
3089
|
+
<o-svc-upgrade [d]="d" [projectKey]="key()" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
2612
3090
|
<div class="o-card" data-testid="svc-ha">
|
|
2613
3091
|
<div class="o-ch"><b>High availability</b><span class="o-r">{{ d.readyNodes }} of {{ d.nodes }} node{{ d.nodes === 1 ? '' : 's' }} ready</span></div>
|
|
2614
3092
|
@switch (engine()?.ha) {
|
|
2615
3093
|
@case ('standby') {
|
|
2616
|
-
<div class="o-row"><span style="flex:1">Standby node with automatic failover@if (d.engine === 'mysql') {<span class="o-faint o-sm"> · two semi-synchronous replicas (MOCO runs 1, 3 or 5)</span>}</span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!d.
|
|
3094
|
+
<div class="o-row"><span style="flex:1">Standby node with automatic failover@if (d.engine === 'mysql') {<span class="o-faint o-sm"> · two semi-synchronous replicas (MOCO runs 1, 3 or 5)</span>}</span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="topology(d, !d.standby, d.standby ? 0 : d.replicas)" aria-label="Standby" data-testid="svc-standby"></button></div>
|
|
2617
3095
|
<div class="o-row"><span style="flex:1">Read replicas@if (d.readHost) {<span class="o-faint o-sm o-mono"> · {{ d.readHost }}</span>}</span><span class="o-faint o-sm" data-testid="svc-replicas">{{ d.replicas }}</span>
|
|
2618
|
-
@if (d.
|
|
3096
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2619
3097
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2620
3098
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby || d.engine === 'mysql', d.replicas + step(d))" [disabled]="busy() || d.replicas + step(d) > (engine()?.maxReplicas ?? 0)" data-testid="svc-replica-add">+ Replica</button>
|
|
2621
3099
|
}</div>
|
|
2622
3100
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2623
3101
|
}
|
|
2624
3102
|
@case ('cluster') {
|
|
2625
|
-
<div class="o-row"><span style="flex:1">3-node cluster<span class="o-faint o-sm"> · {{ d.engine === 'nats' ? 'streams replicated on three servers' : 'quorum queues on three nodes' }}</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!d.
|
|
3103
|
+
<div class="o-row"><span style="flex:1">3-node cluster<span class="o-faint o-sm"> · {{ d.engine === 'nats' ? 'streams replicated on three servers' : 'quorum queues on three nodes' }}</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="topology(d, !d.standby, 0)" aria-label="3-node cluster" data-testid="svc-standby"></button></div>
|
|
2626
3104
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2627
3105
|
}
|
|
2628
3106
|
@case ('replica') {
|
|
2629
|
-
<div class="o-row"><span style="flex:1">Replica with automatic failover<span class="o-faint o-sm"> · three Sentinels promote it if the primary fails</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!d.
|
|
3107
|
+
<div class="o-row"><span style="flex:1">Replica with automatic failover<span class="o-faint o-sm"> · three Sentinels promote it if the primary fails</span></span><button type="button" class="o-tog" [class.o-on]="d.standby" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="topology(d, !d.standby, 0)" aria-label="Replica with failover" data-testid="svc-standby"></button></div>
|
|
2630
3108
|
<div class="o-row"><span style="flex:1">Second replica@if (d.sentinel; as sn) {<span class="o-faint o-sm o-mono"> · Sentinels {{ sn.hosts.length }} × :{{ sn.port }}, master {{ sn.master }}</span>}</span><span class="o-faint o-sm" data-testid="svc-replicas">{{ d.replicas }}</span>
|
|
2631
|
-
@if (d.
|
|
3109
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2632
3110
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - 1)" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2633
3111
|
<button type="button" class="o-btn o-xs" (click)="topology(d, true, d.replicas + 1)" [disabled]="busy() || d.replicas + 1 > (engine()?.maxReplicas ?? 0)" data-testid="svc-replica-add">+ Replica</button>
|
|
2634
3112
|
}</div>
|
|
@@ -2643,11 +3121,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2643
3121
|
<div class="o-card">
|
|
2644
3122
|
<div class="o-ch"><b>Networking</b></div>
|
|
2645
3123
|
<div class="o-row"><span style="flex:1">Private network <span class="o-faint o-sm">· always on, reachable only from attached apps</span></span><span class="o-chip o-grn">On</span></div>
|
|
2646
|
-
<div class="o-row"><span style="flex:1">Public endpoint with trusted sources</span><button type="button" class="o-tog" [class.o-on]="d.publicEnabled" [disabled]="!d.
|
|
3124
|
+
<div class="o-row"><span style="flex:1">Public endpoint with trusted sources</span><button type="button" class="o-tog" [class.o-on]="d.publicEnabled" [disabled]="!has(d, 'services.instance.configure') || busy()" (click)="save(d, { publicEnabled: !d.publicEnabled }, d.publicEnabled ? 'The public endpoint is off.' : 'The public endpoint is on for the trusted sources below.')" aria-label="Public endpoint" data-testid="svc-public"></button></div>
|
|
2647
3125
|
@if (d.publicEnabled) {
|
|
2648
3126
|
<div class="o-row o-wrap" style="background:var(--o-bg)">
|
|
2649
|
-
@for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (d.
|
|
2650
|
-
@if (d.
|
|
3127
|
+
@for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (has(d, 'services.instance.configure')) { <button type="button" class="o-lnk o-faint" (click)="dropCidr(d, c)" [attr.aria-label]="'Remove ' + c">×</button>}</span>}
|
|
3128
|
+
@if (has(d, 'services.instance.configure')) {<input class="o-in o-sm o-mono" style="width:180px" placeholder="+ IP or CIDR" aria-label="Add a trusted source" [ngModel]="cidr()" (ngModelChange)="cidr.set($event)" (keydown.enter)="addCidr(d)" data-testid="svc-cidr" />}
|
|
2651
3129
|
<span class="o-faint o-sm o-r">TLS required</span>
|
|
2652
3130
|
</div>
|
|
2653
3131
|
}
|
|
@@ -2655,22 +3133,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2655
3133
|
<div class="o-card">
|
|
2656
3134
|
<div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
|
|
2657
3135
|
<div class="o-cb o-flex o-wrap">
|
|
2658
|
-
<select class="o-in" style="width:auto" aria-label="Day" [disabled]="!d.
|
|
2659
|
-
<select class="o-in" style="width:auto" aria-label="Hours" [disabled]="!d.
|
|
3136
|
+
<select class="o-in" style="width:auto" aria-label="Day" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.maintenanceDay" (ngModelChange)="save(d, { maintenanceDay: $event }, 'Maintenance window saved.')">@for (x of days; track x.id) {<option [value]="x.id">{{ x.label }}</option>}</select>
|
|
3137
|
+
<select class="o-in" style="width:auto" aria-label="Hours" [disabled]="!has(d, 'services.instance.configure')" [ngModel]="d.maintenanceHour" (ngModelChange)="save(d, { maintenanceHour: +$event }, 'Maintenance window saved.')">@for (h of hours; track h) {<option [ngValue]="h">{{ windowHours(h) }}</option>}</select>
|
|
2660
3138
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
2661
3139
|
</div>
|
|
2662
3140
|
</div>
|
|
2663
3141
|
<div class="o-card" data-testid="svc-params"><div class="o-ch"><b>Engine parameters</b><span class="o-r">validated subset</span></div><div class="o-tbl"><div class="o-tbody">
|
|
2664
3142
|
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2665
3143
|
<div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
|
|
2666
|
-
<div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [placeholder]="p.default" [disabled]="!d.
|
|
3144
|
+
<div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [placeholder]="p.default" [disabled]="!has(d, 'services.instance.configure')" [attr.aria-label]="p.name" [ngModel]="paramDraft()[p.name] ?? p.value ?? ''" (ngModelChange)="setParam(p, $event)" [attr.data-testid]="'svc-param-' + p.name" /></div>
|
|
2667
3145
|
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2668
3146
|
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2669
3147
|
</div>}
|
|
2670
3148
|
</div></div>
|
|
2671
|
-
@if (d.
|
|
3149
|
+
@if (has(d, 'services.instance.configure')) {<div class="o-cf"><span class="o-faint o-sm">{{ paramNote(d) }}</span><button type="button" class="o-btn o-sm o-dark o-r" (click)="saveParams(d)" [disabled]="busy() || !paramsChanged()" data-testid="svc-params-save">Save parameters</button></div>}
|
|
2672
3150
|
</div>
|
|
2673
|
-
@if (d.
|
|
3151
|
+
@if (has(d, 'services.instance.delete') && !d.deletedAt) {
|
|
2674
3152
|
<div class="o-card" style="border-color:var(--o-bad-line)"><div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>Delete {{ d.name }}</b><span class="o-hint">Typed confirmation. Undo within 24 hours; optional final backup kept 7 days. Attached apps lose {{ envVar() }}.</span></div><button type="button" class="o-btn o-sm o-red" (click)="dialog.set({ kind: 'delete', typed: '', finalBackup: true })" data-testid="svc-delete">Delete</button></div></div>
|
|
2675
3153
|
}
|
|
2676
3154
|
</div>
|