@chisquare-tech/origine-ngx-apps-services 0.2.3 → 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) {
|
|
@@ -569,7 +587,7 @@ class ServicesNewPage {
|
|
|
569
587
|
}
|
|
570
588
|
pickEngine(e) {
|
|
571
589
|
if (!e.available) {
|
|
572
|
-
this.error.set(`${e.name}
|
|
590
|
+
this.error.set(`${e.name} cannot be created here.`);
|
|
573
591
|
return;
|
|
574
592
|
}
|
|
575
593
|
this.error.set('');
|
|
@@ -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({
|
|
@@ -645,7 +664,7 @@ class ServicesNewPage {
|
|
|
645
664
|
@for (e of engines(); track e.id) {
|
|
646
665
|
<button type="button" class="o-opt" [class.o-on]="engine()?.id === e.id" [class.o-dis]="!e.available" (click)="pickEngine(e)" [attr.data-testid]="'svc-engine-' + e.id">
|
|
647
666
|
<span class="o-rad" [class.o-on]="engine()?.id === e.id"></span>
|
|
648
|
-
<div class="o-col" style="gap:3px;flex:1"><span class="o-flex"><b>{{ e.name }}</b>@if (e.
|
|
667
|
+
<div class="o-col" style="gap:3px;flex:1"><span class="o-flex"><b>{{ e.name }}</b>@if (!e.available) {<span class="o-chip">Not available</span>}</span><span class="o-faint o-sm o-lh">{{ e.description }}</span></div>
|
|
649
668
|
</button>
|
|
650
669
|
}
|
|
651
670
|
</div>
|
|
@@ -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>
|
|
@@ -769,7 +797,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
769
797
|
@for (e of engines(); track e.id) {
|
|
770
798
|
<button type="button" class="o-opt" [class.o-on]="engine()?.id === e.id" [class.o-dis]="!e.available" (click)="pickEngine(e)" [attr.data-testid]="'svc-engine-' + e.id">
|
|
771
799
|
<span class="o-rad" [class.o-on]="engine()?.id === e.id"></span>
|
|
772
|
-
<div class="o-col" style="gap:3px;flex:1"><span class="o-flex"><b>{{ e.name }}</b>@if (e.
|
|
800
|
+
<div class="o-col" style="gap:3px;flex:1"><span class="o-flex"><b>{{ e.name }}</b>@if (!e.available) {<span class="o-chip">Not available</span>}</span><span class="o-faint o-sm o-lh">{{ e.description }}</span></div>
|
|
773
801
|
</button>
|
|
774
802
|
}
|
|
775
803
|
</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">
|
|
@@ -1260,7 +1307,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1260
1307
|
}]
|
|
1261
1308
|
}], propDecorators: { d: [{ type: i0.Input, args: [{ isSignal: true, alias: "d", required: true }] }], projectKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "projectKey", required: true }] }], dumps: [{ type: i0.Input, args: [{ isSignal: true, alias: "dumps", required: false }] }], done: [{ type: i0.Output, args: ["done"] }], failed: [{ type: i0.Output, args: ["failed"] }] } });
|
|
1262
1309
|
|
|
1263
|
-
const COLOUR = { ERROR: 'var(--o-
|
|
1310
|
+
const COLOUR = { ERROR: 'var(--o-term-del)', FATAL: 'var(--o-term-del)', PANIC: 'var(--o-term-del)', WARNING: 'var(--o-run)' };
|
|
1264
1311
|
// The instance page's Logs tab (prototype "svc · logs"): the engine's own log from its pods —
|
|
1265
1312
|
// all, errors, the slow log, connections — newest first, refreshed every 10 s.
|
|
1266
1313
|
class ServiceLogsTab {
|
|
@@ -1467,7 +1514,7 @@ class ServiceMetricsTab {
|
|
|
1467
1514
|
}
|
|
1468
1515
|
</div>
|
|
1469
1516
|
<div class="o-card">
|
|
1470
|
-
<div class="o-ch"><b>Slow queries</b><span class="o-faint o-sm">the latest from the slow log · over 250 ms</span
|
|
1517
|
+
<div class="o-ch"><b>Slow queries</b><span class="o-faint o-sm">the latest from the slow log · over 250 ms</span></div>
|
|
1471
1518
|
<div class="o-tbl">
|
|
1472
1519
|
<div class="o-thead"><div class="o-tr"><div class="o-th">Query</div><div class="o-th o-num">Duration</div><div class="o-th o-num">When</div></div></div>
|
|
1473
1520
|
<div class="o-tbody">
|
|
@@ -1511,7 +1558,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1511
1558
|
}
|
|
1512
1559
|
</div>
|
|
1513
1560
|
<div class="o-card">
|
|
1514
|
-
<div class="o-ch"><b>Slow queries</b><span class="o-faint o-sm">the latest from the slow log · over 250 ms</span
|
|
1561
|
+
<div class="o-ch"><b>Slow queries</b><span class="o-faint o-sm">the latest from the slow log · over 250 ms</span></div>
|
|
1515
1562
|
<div class="o-tbl">
|
|
1516
1563
|
<div class="o-thead"><div class="o-tr"><div class="o-th">Query</div><div class="o-th o-num">Duration</div><div class="o-th o-num">When</div></div></div>
|
|
1517
1564
|
<div class="o-tbody">
|
|
@@ -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,28 +2280,31 @@ 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
|
-
@if (d.engine === 'postgres') {
|
|
2219
|
-
<div class="o-card o-cb o-flex o-wrap"><div class="o-col" style="gap:2px"><b>Extensions</b><span class="o-hint">From the allow-list: pg_stat_statements, pgcrypto, uuid-ossp, citext, pg_trgm, postgis</span></div><div class="o-flex o-r"><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-sm" disabled>+ Enable</button></div></div>
|
|
2220
|
-
}
|
|
2221
2294
|
}
|
|
2222
2295
|
@case ('backups') {
|
|
2223
2296
|
<div class="o-split o-w340">
|
|
2224
2297
|
<div class="o-card">
|
|
2225
|
-
<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>
|
|
2226
2299
|
@for (b of backups(); track b.name) {
|
|
2227
2300
|
<div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
|
|
2228
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>
|
|
2229
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>
|
|
2230
|
-
@if (b.phase === 'completed' && d.
|
|
2231
|
-
@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>}
|
|
2232
2305
|
</div>
|
|
2233
2306
|
} @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
|
|
2234
|
-
<div class="o-cf">Stored encrypted in a separate failure domain
|
|
2307
|
+
<div class="o-cf">Stored encrypted in a separate failure domain.</div>
|
|
2235
2308
|
</div>
|
|
2236
2309
|
<div class="o-col" style="gap:14px">
|
|
2237
2310
|
@if (d.pitr) {
|
|
@@ -2239,7 +2312,7 @@ class ServicePage {
|
|
|
2239
2312
|
<b>Restore to a point in time</b>
|
|
2240
2313
|
<span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
|
|
2241
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" />
|
|
2242
|
-
<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>
|
|
2243
2316
|
</div>
|
|
2244
2317
|
} @else {
|
|
2245
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>
|
|
@@ -2251,9 +2324,9 @@ class ServicePage {
|
|
|
2251
2324
|
@if (d.engine === 'mysql') {
|
|
2252
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>
|
|
2253
2326
|
} @else {
|
|
2254
|
-
<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>
|
|
2255
2328
|
}
|
|
2256
|
-
<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>
|
|
2257
2330
|
</div>
|
|
2258
2331
|
</div>
|
|
2259
2332
|
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
@@ -2274,34 +2347,33 @@ class ServicePage {
|
|
|
2274
2347
|
<div class="o-card">
|
|
2275
2348
|
<div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
|
|
2276
2349
|
<div class="o-cb o-flex o-wrap">
|
|
2277
|
-
<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">
|
|
2278
2351
|
@for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
|
|
2279
2352
|
</select>
|
|
2280
|
-
<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.
|
|
2281
|
-
<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>
|
|
2282
2355
|
</div>
|
|
2283
|
-
<div class="o-cf"><button type="button" class="o-tog" disabled aria-label="Grow storage automatically (GA)"></button>Grow storage automatically at 85% full, up to 500 GB<b class="o-ph-tag o-ga">GA</b></div>
|
|
2284
2356
|
</div>
|
|
2285
2357
|
<div class="o-card" data-testid="svc-ha">
|
|
2286
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>
|
|
2287
2359
|
@switch (engine()?.ha) {
|
|
2288
2360
|
@case ('standby') {
|
|
2289
|
-
<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>
|
|
2290
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>
|
|
2291
|
-
@if (d.
|
|
2363
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2292
2364
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2293
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>
|
|
2294
2366
|
}</div>
|
|
2295
2367
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2296
2368
|
}
|
|
2297
2369
|
@case ('cluster') {
|
|
2298
|
-
<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>
|
|
2299
2371
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2300
2372
|
}
|
|
2301
2373
|
@case ('replica') {
|
|
2302
|
-
<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>
|
|
2303
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>
|
|
2304
|
-
@if (d.
|
|
2376
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2305
2377
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - 1)" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2306
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>
|
|
2307
2379
|
}</div>
|
|
@@ -2316,11 +2388,11 @@ class ServicePage {
|
|
|
2316
2388
|
<div class="o-card">
|
|
2317
2389
|
<div class="o-ch"><b>Networking</b></div>
|
|
2318
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>
|
|
2319
|
-
<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>
|
|
2320
2392
|
@if (d.publicEnabled) {
|
|
2321
2393
|
<div class="o-row o-wrap" style="background:var(--o-bg)">
|
|
2322
|
-
@for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (d.
|
|
2323
|
-
@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" />}
|
|
2324
2396
|
<span class="o-faint o-sm o-r">TLS required</span>
|
|
2325
2397
|
</div>
|
|
2326
2398
|
}
|
|
@@ -2328,22 +2400,22 @@ class ServicePage {
|
|
|
2328
2400
|
<div class="o-card">
|
|
2329
2401
|
<div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
|
|
2330
2402
|
<div class="o-cb o-flex o-wrap">
|
|
2331
|
-
<select class="o-in" style="width:auto" aria-label="Day" [disabled]="!d.
|
|
2332
|
-
<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>
|
|
2333
2405
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
2334
2406
|
</div>
|
|
2335
2407
|
</div>
|
|
2336
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">
|
|
2337
2409
|
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2338
2410
|
<div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
|
|
2339
|
-
<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>
|
|
2340
2412
|
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2341
2413
|
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2342
2414
|
</div>}
|
|
2343
2415
|
</div></div>
|
|
2344
|
-
@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>}
|
|
2345
2417
|
</div>
|
|
2346
|
-
@if (d.
|
|
2418
|
+
@if (has(d, 'services.instance.delete') && !d.deletedAt) {
|
|
2347
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>
|
|
2348
2420
|
}
|
|
2349
2421
|
</div>
|
|
@@ -2406,8 +2478,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2406
2478
|
</div>
|
|
2407
2479
|
<div class="o-acts">
|
|
2408
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>}
|
|
2409
|
-
@if (d.
|
|
2410
|
-
@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>}
|
|
2411
2483
|
<a class="o-btn" [routerLink]="host.link('dashboards')">Dashboard</a>
|
|
2412
2484
|
</div>
|
|
2413
2485
|
</div>
|
|
@@ -2418,13 +2490,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2418
2490
|
@if (d.deletedAt) {
|
|
2419
2491
|
<div class="o-card o-cb o-flex o-wrap" style="border-color:var(--o-bad-line)" data-testid="svc-deleted">
|
|
2420
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>
|
|
2421
|
-
@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>}
|
|
2422
2494
|
</div>
|
|
2423
2495
|
}
|
|
2424
2496
|
@if (d.restoreOf; as src) {
|
|
2425
2497
|
<div class="o-card o-cb o-flex o-wrap" data-testid="svc-restored">
|
|
2426
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>
|
|
2427
|
-
@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>}
|
|
2428
2500
|
</div>
|
|
2429
2501
|
}
|
|
2430
2502
|
|
|
@@ -2448,8 +2520,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2448
2520
|
<span class="o-k">User</span><span class="o-mono o-sm">{{ c.user }}</span>
|
|
2449
2521
|
<span class="o-k">Password</span>
|
|
2450
2522
|
<span class="o-flex"><span class="o-mono o-sm" data-testid="svc-password">{{ revealed()?.password ?? '••••••••••••••••' }}</span>
|
|
2451
|
-
@if (d.
|
|
2452
|
-
@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>}
|
|
2453
2525
|
</span>
|
|
2454
2526
|
<span class="o-k">SSL mode</span><span class="o-mono o-sm">{{ c.sslMode }}</span>
|
|
2455
2527
|
</div>
|
|
@@ -2461,12 +2533,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2461
2533
|
</div>
|
|
2462
2534
|
<div class="o-col" style="gap:18px">
|
|
2463
2535
|
<div class="o-card">
|
|
2464
|
-
<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>
|
|
2465
2537
|
@for (a of d.attachments; track a.id) {
|
|
2466
2538
|
<div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
|
|
2467
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>}
|
|
2468
2540
|
<span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
|
|
2469
|
-
@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>}
|
|
2470
2542
|
</div>
|
|
2471
2543
|
} @empty {<div class="o-empty">Not attached. Attach it and the app gets {{ envVar() }} on its next release.</div>}
|
|
2472
2544
|
@if (attachOpen()) {
|
|
@@ -2479,15 +2551,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2479
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>}
|
|
2480
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>
|
|
2481
2553
|
<span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
|
|
2482
|
-
<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>
|
|
2483
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>
|
|
2484
2557
|
}
|
|
2485
|
-
<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>
|
|
2486
2559
|
</div>
|
|
2487
2560
|
<div class="o-card">
|
|
2488
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>
|
|
2489
2562
|
@for (b of backups().slice(0, 3); track b.name) {
|
|
2490
|
-
<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>
|
|
2491
2564
|
} @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
|
|
2492
2565
|
<div class="o-cf">{{ windowLine() }}</div>
|
|
2493
2566
|
</div>
|
|
@@ -2498,43 +2571,43 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2498
2571
|
<div class="o-g2" style="align-items:start;gap:18px">
|
|
2499
2572
|
@if (d.engine === 'nats') {
|
|
2500
2573
|
<div class="o-card" data-testid="svc-streams">
|
|
2501
|
-
<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>
|
|
2502
2575
|
@if (addingDb()) {
|
|
2503
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>
|
|
2504
2577
|
}
|
|
2505
2578
|
@for (st of d.streams; track st.name) {
|
|
2506
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>
|
|
2507
|
-
@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>
|
|
2508
2581
|
} @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
|
|
2509
2582
|
<div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
|
|
2510
2583
|
</div>
|
|
2511
2584
|
} @else if (d.engine === 'rabbitmq') {
|
|
2512
2585
|
<div class="o-card" data-testid="svc-vhosts">
|
|
2513
|
-
<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>
|
|
2514
2587
|
@if (addingDb()) {
|
|
2515
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>
|
|
2516
2589
|
}
|
|
2517
2590
|
<div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
|
|
2518
2591
|
@for (v of d.vhosts ?? []; track v) {
|
|
2519
2592
|
<div class="o-row"><span class="o-mono o-b5">{{ v }}</span><span class="o-r o-faint o-sm">quorum queues</span>
|
|
2520
|
-
@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>
|
|
2521
2594
|
}
|
|
2522
2595
|
<div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
|
|
2523
2596
|
</div>
|
|
2524
2597
|
} @else {
|
|
2525
2598
|
<div class="o-card">
|
|
2526
|
-
<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>
|
|
2527
2600
|
@if (addingDb()) {
|
|
2528
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>
|
|
2529
2602
|
}
|
|
2530
2603
|
@for (db of d.databases; track db.name; let first = $first) {
|
|
2531
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>
|
|
2532
|
-
@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>
|
|
2533
2606
|
} @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
|
|
2534
2607
|
</div>
|
|
2535
2608
|
}
|
|
2536
2609
|
<div class="o-card">
|
|
2537
|
-
<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>
|
|
2538
2611
|
@if (addingUser()) {
|
|
2539
2612
|
<div class="o-row" style="background:var(--o-bg)">
|
|
2540
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" />
|
|
@@ -2545,28 +2618,31 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2545
2618
|
@for (u of d.users ?? []; track u.name) {
|
|
2546
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>}
|
|
2547
2620
|
<span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
|
|
2548
|
-
@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>
|
|
2549
2628
|
}
|
|
2550
|
-
<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>
|
|
2551
2630
|
</div>
|
|
2552
2631
|
</div>
|
|
2553
|
-
@if (d.engine === 'postgres') {
|
|
2554
|
-
<div class="o-card o-cb o-flex o-wrap"><div class="o-col" style="gap:2px"><b>Extensions</b><span class="o-hint">From the allow-list: pg_stat_statements, pgcrypto, uuid-ossp, citext, pg_trgm, postgis</span></div><div class="o-flex o-r"><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-sm" disabled>+ Enable</button></div></div>
|
|
2555
|
-
}
|
|
2556
2632
|
}
|
|
2557
2633
|
@case ('backups') {
|
|
2558
2634
|
<div class="o-split o-w340">
|
|
2559
2635
|
<div class="o-card">
|
|
2560
|
-
<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>
|
|
2561
2637
|
@for (b of backups(); track b.name) {
|
|
2562
2638
|
<div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
|
|
2563
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>
|
|
2564
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>
|
|
2565
|
-
@if (b.phase === 'completed' && d.
|
|
2566
|
-
@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>}
|
|
2567
2643
|
</div>
|
|
2568
2644
|
} @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
|
|
2569
|
-
<div class="o-cf">Stored encrypted in a separate failure domain
|
|
2645
|
+
<div class="o-cf">Stored encrypted in a separate failure domain.</div>
|
|
2570
2646
|
</div>
|
|
2571
2647
|
<div class="o-col" style="gap:14px">
|
|
2572
2648
|
@if (d.pitr) {
|
|
@@ -2574,7 +2650,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2574
2650
|
<b>Restore to a point in time</b>
|
|
2575
2651
|
<span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
|
|
2576
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" />
|
|
2577
|
-
<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>
|
|
2578
2654
|
</div>
|
|
2579
2655
|
} @else {
|
|
2580
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>
|
|
@@ -2586,9 +2662,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2586
2662
|
@if (d.engine === 'mysql') {
|
|
2587
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>
|
|
2588
2664
|
} @else {
|
|
2589
|
-
<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>
|
|
2590
2666
|
}
|
|
2591
|
-
<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>
|
|
2592
2668
|
</div>
|
|
2593
2669
|
</div>
|
|
2594
2670
|
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
@@ -2609,34 +2685,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2609
2685
|
<div class="o-card">
|
|
2610
2686
|
<div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
|
|
2611
2687
|
<div class="o-cb o-flex o-wrap">
|
|
2612
|
-
<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">
|
|
2613
2689
|
@for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
|
|
2614
2690
|
</select>
|
|
2615
|
-
<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.
|
|
2616
|
-
<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>
|
|
2617
2693
|
</div>
|
|
2618
|
-
<div class="o-cf"><button type="button" class="o-tog" disabled aria-label="Grow storage automatically (GA)"></button>Grow storage automatically at 85% full, up to 500 GB<b class="o-ph-tag o-ga">GA</b></div>
|
|
2619
2694
|
</div>
|
|
2620
2695
|
<div class="o-card" data-testid="svc-ha">
|
|
2621
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>
|
|
2622
2697
|
@switch (engine()?.ha) {
|
|
2623
2698
|
@case ('standby') {
|
|
2624
|
-
<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>
|
|
2625
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>
|
|
2626
|
-
@if (d.
|
|
2701
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2627
2702
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2628
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>
|
|
2629
2704
|
}</div>
|
|
2630
2705
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2631
2706
|
}
|
|
2632
2707
|
@case ('cluster') {
|
|
2633
|
-
<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>
|
|
2634
2709
|
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2635
2710
|
}
|
|
2636
2711
|
@case ('replica') {
|
|
2637
|
-
<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>
|
|
2638
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>
|
|
2639
|
-
@if (d.
|
|
2714
|
+
@if (has(d, 'services.instance.configure')) {
|
|
2640
2715
|
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - 1)" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2641
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>
|
|
2642
2717
|
}</div>
|
|
@@ -2651,11 +2726,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2651
2726
|
<div class="o-card">
|
|
2652
2727
|
<div class="o-ch"><b>Networking</b></div>
|
|
2653
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>
|
|
2654
|
-
<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>
|
|
2655
2730
|
@if (d.publicEnabled) {
|
|
2656
2731
|
<div class="o-row o-wrap" style="background:var(--o-bg)">
|
|
2657
|
-
@for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (d.
|
|
2658
|
-
@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" />}
|
|
2659
2734
|
<span class="o-faint o-sm o-r">TLS required</span>
|
|
2660
2735
|
</div>
|
|
2661
2736
|
}
|
|
@@ -2663,22 +2738,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2663
2738
|
<div class="o-card">
|
|
2664
2739
|
<div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
|
|
2665
2740
|
<div class="o-cb o-flex o-wrap">
|
|
2666
|
-
<select class="o-in" style="width:auto" aria-label="Day" [disabled]="!d.
|
|
2667
|
-
<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>
|
|
2668
2743
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
2669
2744
|
</div>
|
|
2670
2745
|
</div>
|
|
2671
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">
|
|
2672
2747
|
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2673
2748
|
<div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
|
|
2674
|
-
<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>
|
|
2675
2750
|
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2676
2751
|
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2677
2752
|
</div>}
|
|
2678
2753
|
</div></div>
|
|
2679
|
-
@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>}
|
|
2680
2755
|
</div>
|
|
2681
|
-
@if (d.
|
|
2756
|
+
@if (has(d, 'services.instance.delete') && !d.deletedAt) {
|
|
2682
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>
|
|
2683
2758
|
}
|
|
2684
2759
|
</div>
|