@chisquare-tech/origine-ngx-apps-services 0.2.4 → 0.3.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.
|
@@ -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
|
}
|
|
@@ -308,7 +312,8 @@ class ServicesListPage {
|
|
|
308
312
|
this.backups = backupsLine;
|
|
309
313
|
this.price = money;
|
|
310
314
|
this.rows = computed(() => this.list().items.filter((s) => !s.deletedAt || s.state === 'Deleting'), ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
|
|
311
|
-
|
|
315
|
+
/** services.instance.create in some environment (V4-01). */
|
|
316
|
+
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
317
|
this.quotaLine = computed(() => {
|
|
313
318
|
const q = this.list().quota;
|
|
314
319
|
if (!q)
|
|
@@ -380,8 +385,8 @@ class ServicesListPage {
|
|
|
380
385
|
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
381
386
|
@if (created(); as c) {<div class="o-note" [class.o-grn]="c.running" role="status" data-testid="svc-created">{{ c.text }}</div>}
|
|
382
387
|
<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>
|
|
388
|
+
<div class="o-tbl" style="min-width:960px">
|
|
389
|
+
<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
390
|
<div class="o-tbody">
|
|
386
391
|
@for (v of rows(); track v.id) {
|
|
387
392
|
<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 +394,7 @@ class ServicesListPage {
|
|
|
389
394
|
<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
395
|
<div class="o-td o-mut o-sm">{{ v.plan }} · {{ v.planLabel.split(' · ').slice(0, 2).join(' · ') }} · {{ v.diskGb }} GB</div>
|
|
391
396
|
<div class="o-td o-mut">{{ v.environment || 'shared' }}</div>
|
|
397
|
+
<div class="o-td o-mut o-sm" [attr.data-testid]="'svc-region-' + v.name">{{ v.regionName || v.region || '—' }}</div>
|
|
392
398
|
<div class="o-td"><span [class]="chip(v.state)" [attr.title]="v.message || null">{{ v.state }}</span></div>
|
|
393
399
|
<div class="o-td o-sm">{{ v.attached.length ? v.attached.join(', ') : '—' }}</div>
|
|
394
400
|
<div class="o-td o-faint o-sm">{{ backups(v) }}</div>
|
|
@@ -430,8 +436,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
430
436
|
@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
|
|
431
437
|
@if (created(); as c) {<div class="o-note" [class.o-grn]="c.running" role="status" data-testid="svc-created">{{ c.text }}</div>}
|
|
432
438
|
<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>
|
|
439
|
+
<div class="o-tbl" style="min-width:960px">
|
|
440
|
+
<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
441
|
<div class="o-tbody">
|
|
436
442
|
@for (v of rows(); track v.id) {
|
|
437
443
|
<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 +445,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
439
445
|
<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
446
|
<div class="o-td o-mut o-sm">{{ v.plan }} · {{ v.planLabel.split(' · ').slice(0, 2).join(' · ') }} · {{ v.diskGb }} GB</div>
|
|
441
447
|
<div class="o-td o-mut">{{ v.environment || 'shared' }}</div>
|
|
448
|
+
<div class="o-td o-mut o-sm" [attr.data-testid]="'svc-region-' + v.name">{{ v.regionName || v.region || '—' }}</div>
|
|
442
449
|
<div class="o-td"><span [class]="chip(v.state)" [attr.title]="v.message || null">{{ v.state }}</span></div>
|
|
443
450
|
<div class="o-td o-sm">{{ v.attached.length ? v.attached.join(', ') : '—' }}</div>
|
|
444
451
|
<div class="o-td o-faint o-sm">{{ backups(v) }}</div>
|
|
@@ -509,7 +516,13 @@ class ServicesNewPage {
|
|
|
509
516
|
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
510
517
|
this.engines = computed(() => this.catalog()?.engines ?? [], ...(ngDevMode ? [{ debugName: "engines" }] : /* istanbul ignore next */ []));
|
|
511
518
|
this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
|
|
512
|
-
|
|
519
|
+
/** The regions on offer (D-54), the organization's picked for you; with one, the region is text. */
|
|
520
|
+
this.regions = computed(() => this.host.regions?.() ?? [], ...(ngDevMode ? [{ debugName: "regions" }] : /* istanbul ignore next */ []));
|
|
521
|
+
this.regionId = signal('', ...(ngDevMode ? [{ debugName: "regionId" }] : /* istanbul ignore next */ []));
|
|
522
|
+
this.region = computed(() => {
|
|
523
|
+
const r = this.regions().find((x) => x.id === this.regionId());
|
|
524
|
+
return r?.name || this.host.project()?.region || "Your organization's region";
|
|
525
|
+
}, ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
|
|
513
526
|
this.versionLabel = computed(() => this.engine()?.versions.find((v) => v.id === this.version())?.label ?? this.version(), ...(ngDevMode ? [{ debugName: "versionLabel" }] : /* istanbul ignore next */ []));
|
|
514
527
|
this.envs = computed(() => {
|
|
515
528
|
const set = new Set(['production', 'staging']);
|
|
@@ -532,6 +545,11 @@ class ServicesNewPage {
|
|
|
532
545
|
return '';
|
|
533
546
|
}, ...(ngDevMode ? [{ debugName: "nameError" }] : /* istanbul ignore next */ []));
|
|
534
547
|
this.key = '';
|
|
548
|
+
effect(() => {
|
|
549
|
+
const rs = this.regions();
|
|
550
|
+
if (!untracked(this.regionId) && rs.length)
|
|
551
|
+
this.regionId.set((rs.find((r) => r.default) ?? rs[0]).id);
|
|
552
|
+
});
|
|
535
553
|
effect(() => {
|
|
536
554
|
const p = this.host.project();
|
|
537
555
|
if (p && p.key !== this.key) {
|
|
@@ -611,6 +629,7 @@ class ServicesNewPage {
|
|
|
611
629
|
this.api
|
|
612
630
|
.create(this.key, {
|
|
613
631
|
name: this.name(), engine: e.id, version: this.version(), plan: p.id, environment: env, backupHour: this.hour(), retentionDays: this.keep(),
|
|
632
|
+
region: this.regions().length > 1 ? this.regionId() || undefined : undefined,
|
|
614
633
|
attach: attach ? { app: attach, environment: env || 'production' } : undefined, standby: this.ha() && !!e.ha,
|
|
615
634
|
})
|
|
616
635
|
.subscribe({
|
|
@@ -672,7 +691,16 @@ class ServicesNewPage {
|
|
|
672
691
|
</div></div>
|
|
673
692
|
@if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
|
|
674
693
|
<div class="o-g2">
|
|
675
|
-
<div class="o-fld"><label
|
|
694
|
+
<div class="o-fld"><label for="svc-region">Region</label>
|
|
695
|
+
@if (regions().length > 1) {
|
|
696
|
+
<select id="svc-region" class="o-in" [ngModel]="regionId()" (ngModelChange)="regionId.set($event)" data-testid="svc-region">
|
|
697
|
+
@for (r of regions(); track r.id) {<option [value]="r.id">{{ r.name }}{{ r.default ? ' (your organization’s)' : '' }}</option>}
|
|
698
|
+
</select>
|
|
699
|
+
<span class="o-hint">It stays in this region. Apps attach to it only from an environment in the same region.</span>
|
|
700
|
+
} @else {
|
|
701
|
+
<input id="svc-region" class="o-in" [value]="region()" readonly data-testid="svc-region-one" />
|
|
702
|
+
}
|
|
703
|
+
</div>
|
|
676
704
|
<div class="o-fld"><label>High availability</label><div class="o-flex">
|
|
677
705
|
@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
706
|
@else {<button type="button" class="o-tog" disabled aria-label="High availability"></button>}
|
|
@@ -737,7 +765,7 @@ class ServicesNewPage {
|
|
|
737
765
|
<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
766
|
</div>
|
|
739
767
|
<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' }}
|
|
768
|
+
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }} and no more.</span>
|
|
741
769
|
</div>
|
|
742
770
|
</div>
|
|
743
771
|
</div>
|
|
@@ -796,7 +824,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
796
824
|
</div></div>
|
|
797
825
|
@if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
|
|
798
826
|
<div class="o-g2">
|
|
799
|
-
<div class="o-fld"><label
|
|
827
|
+
<div class="o-fld"><label for="svc-region">Region</label>
|
|
828
|
+
@if (regions().length > 1) {
|
|
829
|
+
<select id="svc-region" class="o-in" [ngModel]="regionId()" (ngModelChange)="regionId.set($event)" data-testid="svc-region">
|
|
830
|
+
@for (r of regions(); track r.id) {<option [value]="r.id">{{ r.name }}{{ r.default ? ' (your organization’s)' : '' }}</option>}
|
|
831
|
+
</select>
|
|
832
|
+
<span class="o-hint">It stays in this region. Apps attach to it only from an environment in the same region.</span>
|
|
833
|
+
} @else {
|
|
834
|
+
<input id="svc-region" class="o-in" [value]="region()" readonly data-testid="svc-region-one" />
|
|
835
|
+
}
|
|
836
|
+
</div>
|
|
800
837
|
<div class="o-fld"><label>High availability</label><div class="o-flex">
|
|
801
838
|
@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
839
|
@else {<button type="button" class="o-tog" disabled aria-label="High availability"></button>}
|
|
@@ -861,7 +898,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
861
898
|
<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
899
|
</div>
|
|
863
900
|
<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' }}
|
|
901
|
+
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }} and no more.</span>
|
|
865
902
|
</div>
|
|
866
903
|
</div>
|
|
867
904
|
</div>
|
|
@@ -878,6 +915,8 @@ class ServiceConsoleTab {
|
|
|
878
915
|
this.api = inject(ServicesApi);
|
|
879
916
|
this.d = input.required(...(ngDevMode ? [{ debugName: "d" }] : /* istanbul ignore next */ []));
|
|
880
917
|
this.projectKey = input.required(...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
|
|
918
|
+
/** The console and the inspector need services.connect on the instance's environment (V4-01). */
|
|
919
|
+
this.may = computed(() => (this.d().caps ?? []).includes('services.connect'), ...(ngDevMode ? [{ debugName: "may" }] : /* istanbul ignore next */ []));
|
|
881
920
|
this.kind = input('sql', ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
|
|
882
921
|
this.sql = computed(() => this.kind() === 'sql', ...(ngDevMode ? [{ debugName: "sql" }] : /* istanbul ignore next */ []));
|
|
883
922
|
this.running = computed(() => ['Running', 'Updating', 'Degraded'].includes(this.d().state), ...(ngDevMode ? [{ debugName: "running" }] : /* istanbul ignore next */ []));
|
|
@@ -964,12 +1003,14 @@ class ServiceConsoleTab {
|
|
|
964
1003
|
}
|
|
965
1004
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceConsoleTab, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
966
1005
|
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 (
|
|
1006
|
+
@if (!may()) {
|
|
1007
|
+
<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>
|
|
1008
|
+
} @else if (sql()) {
|
|
968
1009
|
<div class="o-flex o-wrap">
|
|
969
1010
|
<span class="o-chip" [class.o-grn]="!write()" [class.o-amb]="write()" data-testid="svc-console-mode">{{ write() ? 'Read-write' : 'Read-only' }}</span>
|
|
970
1011
|
<span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as {{ runsAs() }} · audited</span>
|
|
971
1012
|
<span class="o-r"></span>
|
|
972
|
-
@if (
|
|
1013
|
+
@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
1014
|
@if (d().databases.length) {
|
|
974
1015
|
<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
1016
|
@for (x of d().databases; track x.name) {<option [value]="x.name">{{ x.name }}</option>}
|
|
@@ -1027,12 +1068,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1027
1068
|
imports: [FormsModule],
|
|
1028
1069
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1029
1070
|
template: `
|
|
1030
|
-
@if (
|
|
1071
|
+
@if (!may()) {
|
|
1072
|
+
<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>
|
|
1073
|
+
} @else if (sql()) {
|
|
1031
1074
|
<div class="o-flex o-wrap">
|
|
1032
1075
|
<span class="o-chip" [class.o-grn]="!write()" [class.o-amb]="write()" data-testid="svc-console-mode">{{ write() ? 'Read-write' : 'Read-only' }}</span>
|
|
1033
1076
|
<span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as {{ runsAs() }} · audited</span>
|
|
1034
1077
|
<span class="o-r"></span>
|
|
1035
|
-
@if (
|
|
1078
|
+
@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
1079
|
@if (d().databases.length) {
|
|
1037
1080
|
<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
1081
|
@for (x of d().databases; track x.name) {<option [value]="x.name">{{ x.name }}</option>}
|
|
@@ -1105,6 +1148,10 @@ class ServiceDumpsCard {
|
|
|
1105
1148
|
this.size = bytes;
|
|
1106
1149
|
this.when = (s) => when(s);
|
|
1107
1150
|
}
|
|
1151
|
+
/** Whether the caller holds capability id on the instance's environment (V4-01). */
|
|
1152
|
+
may(id) {
|
|
1153
|
+
return (this.d().caps ?? []).includes(id);
|
|
1154
|
+
}
|
|
1108
1155
|
hasBackup() {
|
|
1109
1156
|
return (this.d().backups ?? []).some((b) => b.phase === 'completed');
|
|
1110
1157
|
}
|
|
@@ -1165,7 +1212,7 @@ class ServiceDumpsCard {
|
|
|
1165
1212
|
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-fork-card">
|
|
1166
1213
|
<b>Fork for staging</b>
|
|
1167
1214
|
<span class="o-hint">Copy from the latest backup into a new instance. {{ d().name }} keeps running untouched.</span>
|
|
1168
|
-
@if (
|
|
1215
|
+
@if (may('services.data.export')) {
|
|
1169
1216
|
<div class="o-flex o-wrap">
|
|
1170
1217
|
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-fork-env">
|
|
1171
1218
|
@for (e of envs; track e) {<option [value]="e">{{ e }}</option>}
|
|
@@ -1177,18 +1224,18 @@ class ServiceDumpsCard {
|
|
|
1177
1224
|
</div>
|
|
1178
1225
|
@if (dumps()) {
|
|
1179
1226
|
<div class="o-card" data-testid="svc-dumps">
|
|
1180
|
-
<div class="o-ch"><b>Dumps</b>@if (
|
|
1227
|
+
<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
1228
|
@for (x of d().dumps; track x.id) {
|
|
1182
1229
|
<div class="o-row" [attr.data-testid]="'svc-dump-' + x.id">
|
|
1183
1230
|
<span [class]="'o-dot ' + dot(x)"></span>
|
|
1184
1231
|
<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
1232
|
<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 (
|
|
1233
|
+
@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>}
|
|
1234
|
+
@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
1235
|
</div>
|
|
1189
1236
|
@if (x.error) {<div class="o-row o-sm" style="color:var(--o-bad-tx)">{{ x.error }}</div>}
|
|
1190
1237
|
} @empty {<div class="o-empty">No dumps yet. Export one to download it, or import a dump into {{ d().name }}.</div>}
|
|
1191
|
-
@if (
|
|
1238
|
+
@if (may('services.data.import')) {
|
|
1192
1239
|
<div class="o-cb o-col" style="gap:8px;border-top:1px solid var(--o-line-2)">
|
|
1193
1240
|
<b class="o-sm">Import</b>
|
|
1194
1241
|
<div class="o-flex o-wrap">
|
|
@@ -1217,7 +1264,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1217
1264
|
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-fork-card">
|
|
1218
1265
|
<b>Fork for staging</b>
|
|
1219
1266
|
<span class="o-hint">Copy from the latest backup into a new instance. {{ d().name }} keeps running untouched.</span>
|
|
1220
|
-
@if (
|
|
1267
|
+
@if (may('services.data.export')) {
|
|
1221
1268
|
<div class="o-flex o-wrap">
|
|
1222
1269
|
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-fork-env">
|
|
1223
1270
|
@for (e of envs; track e) {<option [value]="e">{{ e }}</option>}
|
|
@@ -1229,18 +1276,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1229
1276
|
</div>
|
|
1230
1277
|
@if (dumps()) {
|
|
1231
1278
|
<div class="o-card" data-testid="svc-dumps">
|
|
1232
|
-
<div class="o-ch"><b>Dumps</b>@if (
|
|
1279
|
+
<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
1280
|
@for (x of d().dumps; track x.id) {
|
|
1234
1281
|
<div class="o-row" [attr.data-testid]="'svc-dump-' + x.id">
|
|
1235
1282
|
<span [class]="'o-dot ' + dot(x)"></span>
|
|
1236
1283
|
<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
1284
|
<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 (
|
|
1285
|
+
@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>}
|
|
1286
|
+
@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
1287
|
</div>
|
|
1241
1288
|
@if (x.error) {<div class="o-row o-sm" style="color:var(--o-bad-tx)">{{ x.error }}</div>}
|
|
1242
1289
|
} @empty {<div class="o-empty">No dumps yet. Export one to download it, or import a dump into {{ d().name }}.</div>}
|
|
1243
|
-
@if (
|
|
1290
|
+
@if (may('services.data.import')) {
|
|
1244
1291
|
<div class="o-cb o-col" style="gap:8px;border-top:1px solid var(--o-line-2)">
|
|
1245
1292
|
<b class="o-sm">Import</b>
|
|
1246
1293
|
<div class="o-flex o-wrap">
|
|
@@ -1603,7 +1650,16 @@ class ServicePage {
|
|
|
1603
1650
|
this.engine = computed(() => this.catalog()?.engines.find((e) => e.id === this.d()?.engine) ?? null, ...(ngDevMode ? [{ debugName: "engine" }] : /* istanbul ignore next */ []));
|
|
1604
1651
|
this.envVar = computed(() => this.engine()?.envVar ?? 'DATABASE_URL', ...(ngDevMode ? [{ debugName: "envVar" }] : /* istanbul ignore next */ []));
|
|
1605
1652
|
this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
|
|
1606
|
-
|
|
1653
|
+
/** Where the instance runs (D-54): its own region, the organization's by default. */
|
|
1654
|
+
this.region = computed(() => this.d()?.regionName || this.d()?.region || this.host.project()?.region || 'your region', ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
|
|
1655
|
+
/** The region of the chosen app's chosen environment, when deploy says (D-54). */
|
|
1656
|
+
this.attRegion = computed(() => this.apps().find((a) => a.name === this.attApp())?.regions?.[this.attEnv()] ?? '', ...(ngDevMode ? [{ debugName: "attRegion" }] : /* istanbul ignore next */ []));
|
|
1657
|
+
/** An app attaches only to a service in its own region (D-54). */
|
|
1658
|
+
this.attOtherRegion = computed(() => {
|
|
1659
|
+
const d = this.d(), r = this.attRegion();
|
|
1660
|
+
return !!d?.region && !!r && r !== d.region;
|
|
1661
|
+
}, ...(ngDevMode ? [{ debugName: "attOtherRegion" }] : /* istanbul ignore next */ []));
|
|
1662
|
+
this.rotationPeriods = [0, 30, 60, 90, 180, 365];
|
|
1607
1663
|
this.conn = computed(() => this.revealed()?.connection ?? this.d()?.connection ?? null, ...(ngDevMode ? [{ debugName: "conn" }] : /* istanbul ignore next */ []));
|
|
1608
1664
|
this.snippetKeys = computed(() => {
|
|
1609
1665
|
const s = this.conn()?.snippets ?? {};
|
|
@@ -1848,6 +1904,19 @@ class ServicePage {
|
|
|
1848
1904
|
this.notice.set('Credential reveal recorded in the audit log.');
|
|
1849
1905
|
}, 'The password could not be revealed.');
|
|
1850
1906
|
}
|
|
1907
|
+
/** Whether the caller holds capability id on the instance's environment (V4-01). */
|
|
1908
|
+
has(d, id) {
|
|
1909
|
+
return (d.caps ?? []).includes(id);
|
|
1910
|
+
}
|
|
1911
|
+
/** A user's scheduled rotation (V0-22): every 30 to 365 days, or never. */
|
|
1912
|
+
setRotation(d, user, days) {
|
|
1913
|
+
this.run(this.api.setRotation(this.key(), d.id, user, days), () => {
|
|
1914
|
+
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.`);
|
|
1915
|
+
}, 'The rotation schedule could not be saved.');
|
|
1916
|
+
}
|
|
1917
|
+
rotationLine(u) {
|
|
1918
|
+
return u.rotateEveryDays && u.nextRotation ? 'next ' + shortDate(u.nextRotation) : '';
|
|
1919
|
+
}
|
|
1851
1920
|
attach(d) {
|
|
1852
1921
|
const app = this.attApp().trim();
|
|
1853
1922
|
const body = { app, environment: this.attEnv(), prefix: this.attPrefix().trim(), ownUser: this.attOwn(), database: d.engine === 'rabbitmq' ? this.attVhost() : undefined };
|
|
@@ -2071,8 +2140,8 @@ class ServicePage {
|
|
|
2071
2140
|
</div>
|
|
2072
2141
|
<div class="o-acts">
|
|
2073
2142
|
@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.
|
|
2143
|
+
@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>}
|
|
2144
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn" (click)="setTab('settings')">Resize</button>}
|
|
2076
2145
|
<a class="o-btn" [routerLink]="host.link('dashboards')">Dashboard</a>
|
|
2077
2146
|
</div>
|
|
2078
2147
|
</div>
|
|
@@ -2083,13 +2152,13 @@ class ServicePage {
|
|
|
2083
2152
|
@if (d.deletedAt) {
|
|
2084
2153
|
<div class="o-card o-cb o-flex o-wrap" style="border-color:var(--o-bad-line)" data-testid="svc-deleted">
|
|
2085
2154
|
<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.
|
|
2155
|
+
@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
2156
|
</div>
|
|
2088
2157
|
}
|
|
2089
2158
|
@if (d.restoreOf; as src) {
|
|
2090
2159
|
<div class="o-card o-cb o-flex o-wrap" data-testid="svc-restored">
|
|
2091
2160
|
<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.
|
|
2161
|
+
@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
2162
|
</div>
|
|
2094
2163
|
}
|
|
2095
2164
|
|
|
@@ -2113,8 +2182,8 @@ class ServicePage {
|
|
|
2113
2182
|
<span class="o-k">User</span><span class="o-mono o-sm">{{ c.user }}</span>
|
|
2114
2183
|
<span class="o-k">Password</span>
|
|
2115
2184
|
<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">
|
|
2185
|
+
@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>}
|
|
2186
|
+
@else {<span class="o-faint o-xs">Your role cannot reveal credentials in {{ d.environment }}</span>}
|
|
2118
2187
|
</span>
|
|
2119
2188
|
<span class="o-k">SSL mode</span><span class="o-mono o-sm">{{ c.sslMode }}</span>
|
|
2120
2189
|
</div>
|
|
@@ -2126,12 +2195,12 @@ class ServicePage {
|
|
|
2126
2195
|
</div>
|
|
2127
2196
|
<div class="o-col" style="gap:18px">
|
|
2128
2197
|
<div class="o-card">
|
|
2129
|
-
<div class="o-ch"><b>Attached apps</b>@if (d.
|
|
2198
|
+
<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
2199
|
@for (a of d.attachments; track a.id) {
|
|
2131
2200
|
<div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
|
|
2132
2201
|
<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
2202
|
<span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
|
|
2134
|
-
@if (d.
|
|
2203
|
+
@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
2204
|
</div>
|
|
2136
2205
|
} @empty {<div class="o-empty">Not attached. Attach it and the app gets {{ envVar() }} on its next release.</div>}
|
|
2137
2206
|
@if (attachOpen()) {
|
|
@@ -2144,15 +2213,16 @@ class ServicePage {
|
|
|
2144
2213
|
@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
2214
|
<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
2215
|
<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>
|
|
2216
|
+
<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
2217
|
</div>
|
|
2218
|
+
<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
2219
|
}
|
|
2150
|
-
<div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.
|
|
2220
|
+
<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
2221
|
</div>
|
|
2152
2222
|
<div class="o-card">
|
|
2153
2223
|
<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
2224
|
@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.
|
|
2225
|
+
<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
2226
|
} @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
|
|
2157
2227
|
<div class="o-cf">{{ windowLine() }}</div>
|
|
2158
2228
|
</div>
|
|
@@ -2163,43 +2233,43 @@ class ServicePage {
|
|
|
2163
2233
|
<div class="o-g2" style="align-items:start;gap:18px">
|
|
2164
2234
|
@if (d.engine === 'nats') {
|
|
2165
2235
|
<div class="o-card" data-testid="svc-streams">
|
|
2166
|
-
<div class="o-ch"><b>Streams</b>@if (d.
|
|
2236
|
+
<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
2237
|
@if (addingDb()) {
|
|
2168
2238
|
<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
2239
|
}
|
|
2170
2240
|
@for (st of d.streams; track st.name) {
|
|
2171
2241
|
<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.
|
|
2242
|
+
@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
2243
|
} @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
|
|
2174
2244
|
<div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
|
|
2175
2245
|
</div>
|
|
2176
2246
|
} @else if (d.engine === 'rabbitmq') {
|
|
2177
2247
|
<div class="o-card" data-testid="svc-vhosts">
|
|
2178
|
-
<div class="o-ch"><b>Virtual hosts</b>@if (d.
|
|
2248
|
+
<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
2249
|
@if (addingDb()) {
|
|
2180
2250
|
<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
2251
|
}
|
|
2182
2252
|
<div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
|
|
2183
2253
|
@for (v of d.vhosts ?? []; track v) {
|
|
2184
2254
|
<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.
|
|
2255
|
+
@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
2256
|
}
|
|
2187
2257
|
<div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
|
|
2188
2258
|
</div>
|
|
2189
2259
|
} @else {
|
|
2190
2260
|
<div class="o-card">
|
|
2191
|
-
<div class="o-ch"><b>Databases</b>@if (d.
|
|
2261
|
+
<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
2262
|
@if (addingDb()) {
|
|
2193
2263
|
<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
2264
|
}
|
|
2195
2265
|
@for (db of d.databases; track db.name; let first = $first) {
|
|
2196
2266
|
<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.
|
|
2267
|
+
@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>
|
|
2198
2268
|
} @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
|
|
2199
2269
|
</div>
|
|
2200
2270
|
}
|
|
2201
2271
|
<div class="o-card">
|
|
2202
|
-
<div class="o-ch"><b>Users</b>@if (d.
|
|
2272
|
+
<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
2273
|
@if (addingUser()) {
|
|
2204
2274
|
<div class="o-row" style="background:var(--o-bg)">
|
|
2205
2275
|
<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 +2280,28 @@ class ServicePage {
|
|
|
2210
2280
|
@for (u of d.users ?? []; track u.name) {
|
|
2211
2281
|
<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
2282
|
<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
|
|
2283
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {
|
|
2284
|
+
<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">
|
|
2285
|
+
@for (p of rotationPeriods; track p) {<option [ngValue]="p">{{ p ? 'Rotate every ' + p + ' days' : 'No scheduled rotation' }}</option>}
|
|
2286
|
+
</select>
|
|
2287
|
+
} @else if (u.rotateEveryDays) {<span class="o-faint o-xs">every {{ u.rotateEveryDays }} days</span>}
|
|
2288
|
+
@if (u.rotateEveryDays && u.nextRotation) {<span class="o-faint o-xs" [attr.data-testid]="'svc-user-next-' + u.name">{{ rotationLine(u) }}</span>}
|
|
2289
|
+
@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
2290
|
}
|
|
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
|
|
2291
|
+
<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
2292
|
</div>
|
|
2217
2293
|
</div>
|
|
2218
2294
|
}
|
|
2219
2295
|
@case ('backups') {
|
|
2220
2296
|
<div class="o-split o-w340">
|
|
2221
2297
|
<div class="o-card">
|
|
2222
|
-
<div class="o-ch"><b>Backups</b>@if (d.
|
|
2298
|
+
<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
2299
|
@for (b of backups(); track b.name) {
|
|
2224
2300
|
<div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
|
|
2225
2301
|
<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
2302
|
<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.
|
|
2303
|
+
@if (b.phase === 'completed' && has(d, 'services.data.import')) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore</button>}
|
|
2304
|
+
@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
2305
|
</div>
|
|
2230
2306
|
} @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
|
|
2231
2307
|
<div class="o-cf">Stored encrypted in a separate failure domain.</div>
|
|
@@ -2236,7 +2312,7 @@ class ServicePage {
|
|
|
2236
2312
|
<b>Restore to a point in time</b>
|
|
2237
2313
|
<span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
|
|
2238
2314
|
<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.
|
|
2315
|
+
<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
2316
|
</div>
|
|
2241
2317
|
} @else {
|
|
2242
2318
|
<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,9 +2324,9 @@ class ServicePage {
|
|
|
2248
2324
|
@if (d.engine === 'mysql') {
|
|
2249
2325
|
<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
2326
|
} @else {
|
|
2251
|
-
<div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.
|
|
2327
|
+
<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
2328
|
}
|
|
2253
|
-
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.
|
|
2329
|
+
<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
2330
|
</div>
|
|
2255
2331
|
</div>
|
|
2256
2332
|
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
@@ -2271,33 +2347,33 @@ class ServicePage {
|
|
|
2271
2347
|
<div class="o-card">
|
|
2272
2348
|
<div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
|
|
2273
2349
|
<div class="o-cb o-flex o-wrap">
|
|
2274
|
-
<select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!d.
|
|
2350
|
+
<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
2351
|
@for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
|
|
2276
2352
|
</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.
|
|
2353
|
+
<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>
|
|
2354
|
+
<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
2355
|
</div>
|
|
2280
2356
|
</div>
|
|
2281
2357
|
<div class="o-card" data-testid="svc-ha">
|
|
2282
2358
|
<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
2359
|
@switch (engine()?.ha) {
|
|
2284
2360
|
@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.
|
|
2361
|
+
<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
2362
|
<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.
|
|
2363
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2288
2364
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2289
2365
|
<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
2366
|
}</div>
|
|
2291
2367
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2292
2368
|
}
|
|
2293
2369
|
@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.
|
|
2370
|
+
<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
2371
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2296
2372
|
}
|
|
2297
2373
|
@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.
|
|
2374
|
+
<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
2375
|
<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.
|
|
2376
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2301
2377
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - 1)" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2302
2378
|
<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
2379
|
}</div>
|
|
@@ -2312,11 +2388,11 @@ class ServicePage {
|
|
|
2312
2388
|
<div class="o-card">
|
|
2313
2389
|
<div class="o-ch"><b>Networking</b></div>
|
|
2314
2390
|
<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.
|
|
2391
|
+
<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
2392
|
@if (d.publicEnabled) {
|
|
2317
2393
|
<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.
|
|
2394
|
+
@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>}
|
|
2395
|
+
@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
2396
|
<span class="o-faint o-sm o-r">TLS required</span>
|
|
2321
2397
|
</div>
|
|
2322
2398
|
}
|
|
@@ -2324,22 +2400,22 @@ class ServicePage {
|
|
|
2324
2400
|
<div class="o-card">
|
|
2325
2401
|
<div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
|
|
2326
2402
|
<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.
|
|
2403
|
+
<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>
|
|
2404
|
+
<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
2405
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
2330
2406
|
</div>
|
|
2331
2407
|
</div>
|
|
2332
2408
|
<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
2409
|
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2334
2410
|
<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.
|
|
2411
|
+
<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
2412
|
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2337
2413
|
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2338
2414
|
</div>}
|
|
2339
2415
|
</div></div>
|
|
2340
|
-
@if (d.
|
|
2416
|
+
@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
2417
|
</div>
|
|
2342
|
-
@if (d.
|
|
2418
|
+
@if (has(d, 'services.instance.delete') && !d.deletedAt) {
|
|
2343
2419
|
<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
2420
|
}
|
|
2345
2421
|
</div>
|
|
@@ -2402,8 +2478,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2402
2478
|
</div>
|
|
2403
2479
|
<div class="o-acts">
|
|
2404
2480
|
@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.
|
|
2481
|
+
@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>}
|
|
2482
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {<button type="button" class="o-btn" (click)="setTab('settings')">Resize</button>}
|
|
2407
2483
|
<a class="o-btn" [routerLink]="host.link('dashboards')">Dashboard</a>
|
|
2408
2484
|
</div>
|
|
2409
2485
|
</div>
|
|
@@ -2414,13 +2490,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2414
2490
|
@if (d.deletedAt) {
|
|
2415
2491
|
<div class="o-card o-cb o-flex o-wrap" style="border-color:var(--o-bad-line)" data-testid="svc-deleted">
|
|
2416
2492
|
<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.
|
|
2493
|
+
@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
2494
|
</div>
|
|
2419
2495
|
}
|
|
2420
2496
|
@if (d.restoreOf; as src) {
|
|
2421
2497
|
<div class="o-card o-cb o-flex o-wrap" data-testid="svc-restored">
|
|
2422
2498
|
<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.
|
|
2499
|
+
@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
2500
|
</div>
|
|
2425
2501
|
}
|
|
2426
2502
|
|
|
@@ -2444,8 +2520,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2444
2520
|
<span class="o-k">User</span><span class="o-mono o-sm">{{ c.user }}</span>
|
|
2445
2521
|
<span class="o-k">Password</span>
|
|
2446
2522
|
<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">
|
|
2523
|
+
@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>}
|
|
2524
|
+
@else {<span class="o-faint o-xs">Your role cannot reveal credentials in {{ d.environment }}</span>}
|
|
2449
2525
|
</span>
|
|
2450
2526
|
<span class="o-k">SSL mode</span><span class="o-mono o-sm">{{ c.sslMode }}</span>
|
|
2451
2527
|
</div>
|
|
@@ -2457,12 +2533,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2457
2533
|
</div>
|
|
2458
2534
|
<div class="o-col" style="gap:18px">
|
|
2459
2535
|
<div class="o-card">
|
|
2460
|
-
<div class="o-ch"><b>Attached apps</b>@if (d.
|
|
2536
|
+
<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
2537
|
@for (a of d.attachments; track a.id) {
|
|
2462
2538
|
<div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
|
|
2463
2539
|
<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
2540
|
<span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
|
|
2465
|
-
@if (d.
|
|
2541
|
+
@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
2542
|
</div>
|
|
2467
2543
|
} @empty {<div class="o-empty">Not attached. Attach it and the app gets {{ envVar() }} on its next release.</div>}
|
|
2468
2544
|
@if (attachOpen()) {
|
|
@@ -2475,15 +2551,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2475
2551
|
@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
2552
|
<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
2553
|
<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>
|
|
2554
|
+
<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
2555
|
</div>
|
|
2556
|
+
<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
2557
|
}
|
|
2481
|
-
<div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.
|
|
2558
|
+
<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
2559
|
</div>
|
|
2483
2560
|
<div class="o-card">
|
|
2484
2561
|
<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
2562
|
@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.
|
|
2563
|
+
<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
2564
|
} @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
|
|
2488
2565
|
<div class="o-cf">{{ windowLine() }}</div>
|
|
2489
2566
|
</div>
|
|
@@ -2494,43 +2571,43 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2494
2571
|
<div class="o-g2" style="align-items:start;gap:18px">
|
|
2495
2572
|
@if (d.engine === 'nats') {
|
|
2496
2573
|
<div class="o-card" data-testid="svc-streams">
|
|
2497
|
-
<div class="o-ch"><b>Streams</b>@if (d.
|
|
2574
|
+
<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
2575
|
@if (addingDb()) {
|
|
2499
2576
|
<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
2577
|
}
|
|
2501
2578
|
@for (st of d.streams; track st.name) {
|
|
2502
2579
|
<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.
|
|
2580
|
+
@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
2581
|
} @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
|
|
2505
2582
|
<div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
|
|
2506
2583
|
</div>
|
|
2507
2584
|
} @else if (d.engine === 'rabbitmq') {
|
|
2508
2585
|
<div class="o-card" data-testid="svc-vhosts">
|
|
2509
|
-
<div class="o-ch"><b>Virtual hosts</b>@if (d.
|
|
2586
|
+
<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
2587
|
@if (addingDb()) {
|
|
2511
2588
|
<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
2589
|
}
|
|
2513
2590
|
<div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
|
|
2514
2591
|
@for (v of d.vhosts ?? []; track v) {
|
|
2515
2592
|
<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.
|
|
2593
|
+
@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
2594
|
}
|
|
2518
2595
|
<div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
|
|
2519
2596
|
</div>
|
|
2520
2597
|
} @else {
|
|
2521
2598
|
<div class="o-card">
|
|
2522
|
-
<div class="o-ch"><b>Databases</b>@if (d.
|
|
2599
|
+
<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
2600
|
@if (addingDb()) {
|
|
2524
2601
|
<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
2602
|
}
|
|
2526
2603
|
@for (db of d.databases; track db.name; let first = $first) {
|
|
2527
2604
|
<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.
|
|
2605
|
+
@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>
|
|
2529
2606
|
} @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
|
|
2530
2607
|
</div>
|
|
2531
2608
|
}
|
|
2532
2609
|
<div class="o-card">
|
|
2533
|
-
<div class="o-ch"><b>Users</b>@if (d.
|
|
2610
|
+
<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
2611
|
@if (addingUser()) {
|
|
2535
2612
|
<div class="o-row" style="background:var(--o-bg)">
|
|
2536
2613
|
<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 +2618,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2541
2618
|
@for (u of d.users ?? []; track u.name) {
|
|
2542
2619
|
<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
2620
|
<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
|
|
2621
|
+
@if (has(d, 'services.instance.configure') && !d.deletedAt) {
|
|
2622
|
+
<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">
|
|
2623
|
+
@for (p of rotationPeriods; track p) {<option [ngValue]="p">{{ p ? 'Rotate every ' + p + ' days' : 'No scheduled rotation' }}</option>}
|
|
2624
|
+
</select>
|
|
2625
|
+
} @else if (u.rotateEveryDays) {<span class="o-faint o-xs">every {{ u.rotateEveryDays }} days</span>}
|
|
2626
|
+
@if (u.rotateEveryDays && u.nextRotation) {<span class="o-faint o-xs" [attr.data-testid]="'svc-user-next-' + u.name">{{ rotationLine(u) }}</span>}
|
|
2627
|
+
@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
2628
|
}
|
|
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
|
|
2629
|
+
<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
2630
|
</div>
|
|
2548
2631
|
</div>
|
|
2549
2632
|
}
|
|
2550
2633
|
@case ('backups') {
|
|
2551
2634
|
<div class="o-split o-w340">
|
|
2552
2635
|
<div class="o-card">
|
|
2553
|
-
<div class="o-ch"><b>Backups</b>@if (d.
|
|
2636
|
+
<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
2637
|
@for (b of backups(); track b.name) {
|
|
2555
2638
|
<div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
|
|
2556
2639
|
<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
2640
|
<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.
|
|
2641
|
+
@if (b.phase === 'completed' && has(d, 'services.data.import')) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore</button>}
|
|
2642
|
+
@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
2643
|
</div>
|
|
2561
2644
|
} @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
|
|
2562
2645
|
<div class="o-cf">Stored encrypted in a separate failure domain.</div>
|
|
@@ -2567,7 +2650,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2567
2650
|
<b>Restore to a point in time</b>
|
|
2568
2651
|
<span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
|
|
2569
2652
|
<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.
|
|
2653
|
+
<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
2654
|
</div>
|
|
2572
2655
|
} @else {
|
|
2573
2656
|
<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,9 +2662,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2579
2662
|
@if (d.engine === 'mysql') {
|
|
2580
2663
|
<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
2664
|
} @else {
|
|
2582
|
-
<div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.
|
|
2665
|
+
<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
2666
|
}
|
|
2584
|
-
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.
|
|
2667
|
+
<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
2668
|
</div>
|
|
2586
2669
|
</div>
|
|
2587
2670
|
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
@@ -2602,33 +2685,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2602
2685
|
<div class="o-card">
|
|
2603
2686
|
<div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
|
|
2604
2687
|
<div class="o-cb o-flex o-wrap">
|
|
2605
|
-
<select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!d.
|
|
2688
|
+
<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
2689
|
@for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
|
|
2607
2690
|
</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.
|
|
2691
|
+
<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>
|
|
2692
|
+
<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
2693
|
</div>
|
|
2611
2694
|
</div>
|
|
2612
2695
|
<div class="o-card" data-testid="svc-ha">
|
|
2613
2696
|
<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
2697
|
@switch (engine()?.ha) {
|
|
2615
2698
|
@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.
|
|
2699
|
+
<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
2700
|
<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.
|
|
2701
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2619
2702
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2620
2703
|
<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
2704
|
}</div>
|
|
2622
2705
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2623
2706
|
}
|
|
2624
2707
|
@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.
|
|
2708
|
+
<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
2709
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2627
2710
|
}
|
|
2628
2711
|
@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.
|
|
2712
|
+
<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
2713
|
<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.
|
|
2714
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2632
2715
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - 1)" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2633
2716
|
<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
2717
|
}</div>
|
|
@@ -2643,11 +2726,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2643
2726
|
<div class="o-card">
|
|
2644
2727
|
<div class="o-ch"><b>Networking</b></div>
|
|
2645
2728
|
<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.
|
|
2729
|
+
<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
2730
|
@if (d.publicEnabled) {
|
|
2648
2731
|
<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.
|
|
2732
|
+
@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>}
|
|
2733
|
+
@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
2734
|
<span class="o-faint o-sm o-r">TLS required</span>
|
|
2652
2735
|
</div>
|
|
2653
2736
|
}
|
|
@@ -2655,22 +2738,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2655
2738
|
<div class="o-card">
|
|
2656
2739
|
<div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
|
|
2657
2740
|
<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.
|
|
2741
|
+
<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>
|
|
2742
|
+
<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
2743
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
2661
2744
|
</div>
|
|
2662
2745
|
</div>
|
|
2663
2746
|
<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
2747
|
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2665
2748
|
<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.
|
|
2749
|
+
<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
2750
|
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2668
2751
|
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2669
2752
|
</div>}
|
|
2670
2753
|
</div></div>
|
|
2671
|
-
@if (d.
|
|
2754
|
+
@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
2755
|
</div>
|
|
2673
|
-
@if (d.
|
|
2756
|
+
@if (has(d, 'services.instance.delete') && !d.deletedAt) {
|
|
2674
2757
|
<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
2758
|
}
|
|
2676
2759
|
</div>
|