@chisquare-tech/origine-ngx-apps-services 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { HttpClient } from '@angular/common/http';
|
|
1
2
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { inject, Injectable, signal, computed, effect, untracked, DestroyRef, ChangeDetectionStrategy, Component, input } from '@angular/core';
|
|
3
|
+
import { inject, Injectable, signal, computed, effect, untracked, DestroyRef, ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
|
3
4
|
import { ApiService } from '@thescaffold/ngx-core';
|
|
4
5
|
import { map, forkJoin, of } from 'rxjs';
|
|
5
6
|
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
|
|
@@ -24,6 +25,7 @@ function apiMessage(err, fallback = 'Something went wrong. Try again.') {
|
|
|
24
25
|
class ServicesApi {
|
|
25
26
|
constructor() {
|
|
26
27
|
this.api = inject(ApiService);
|
|
28
|
+
this.http = inject(HttpClient);
|
|
27
29
|
}
|
|
28
30
|
i(project, id = '', path = '') {
|
|
29
31
|
return `${S}/project/${enc(project)}/instance${id ? '/' + enc(id) : ''}${path ? '/' + path : ''}`;
|
|
@@ -94,6 +96,45 @@ class ServicesApi {
|
|
|
94
96
|
logs(project, id, kind, limit = 200) {
|
|
95
97
|
return this.api.get(this.i(project, id, 'logs'), { kind, limit }).pipe(map((r) => data(r)));
|
|
96
98
|
}
|
|
99
|
+
// ---- v1 ----
|
|
100
|
+
addStream(project, id, s) {
|
|
101
|
+
return this.api.post(this.i(project, id, 'stream'), s).pipe(map((r) => data(r)));
|
|
102
|
+
}
|
|
103
|
+
removeStream(project, id, name) {
|
|
104
|
+
return this.api.delete(this.i(project, id, 'stream/' + enc(name)));
|
|
105
|
+
}
|
|
106
|
+
addVhost(project, id, name) {
|
|
107
|
+
return this.api.post(this.i(project, id, 'vhost'), { name }).pipe(map((r) => data(r)));
|
|
108
|
+
}
|
|
109
|
+
removeVhost(project, id, name) {
|
|
110
|
+
return this.api.delete(this.i(project, id, 'vhost/' + enc(name)));
|
|
111
|
+
}
|
|
112
|
+
fork(project, id, body) {
|
|
113
|
+
return this.api.post(this.i(project, id, 'fork'), body).pipe(map((r) => data(r)));
|
|
114
|
+
}
|
|
115
|
+
exportDump(project, id, database = '') {
|
|
116
|
+
return this.api.post(this.i(project, id, 'dump'), { database }).pipe(map((r) => data(r)));
|
|
117
|
+
}
|
|
118
|
+
importRemote(project, id, database, remote) {
|
|
119
|
+
return this.api.post(this.i(project, id, 'dump/import'), { database, remote }).pipe(map((r) => data(r)));
|
|
120
|
+
}
|
|
121
|
+
deleteDump(project, id, dump) {
|
|
122
|
+
return this.api.delete(this.i(project, id, 'dump/' + enc(dump)));
|
|
123
|
+
}
|
|
124
|
+
/** Streams an uploaded dump to the cell (outside the JSON API: /x/services/…). */
|
|
125
|
+
importUpload(project, id, database, file) {
|
|
126
|
+
return this.http.put(`/x/services/${enc(project)}/${enc(id)}/dump`, file, { params: { database, file: file.name }, headers: { 'content-type': 'application/octet-stream' } })
|
|
127
|
+
.pipe(map((r) => data(r)));
|
|
128
|
+
}
|
|
129
|
+
downloadDump(project, id, dump) {
|
|
130
|
+
return this.http.get(`/x/services/${enc(project)}/${enc(id)}/dump/${enc(dump)}`, { responseType: 'blob' });
|
|
131
|
+
}
|
|
132
|
+
query(project, id, body) {
|
|
133
|
+
return this.api.post(this.i(project, id, 'query'), body).pipe(map((r) => data(r)));
|
|
134
|
+
}
|
|
135
|
+
inspect(project, id, body) {
|
|
136
|
+
return this.api.post(this.i(project, id, 'inspect'), body).pipe(map((r) => data(r)));
|
|
137
|
+
}
|
|
97
138
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesApi, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
98
139
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesApi, providedIn: 'root' }); }
|
|
99
140
|
}
|
|
@@ -354,8 +395,8 @@ class ServicesListPage {
|
|
|
354
395
|
</div>
|
|
355
396
|
</div>
|
|
356
397
|
<div class="o-g2">
|
|
357
|
-
<
|
|
358
|
-
<
|
|
398
|
+
<a class="o-card o-cb o-flex" [routerLink]="host.link('services/new')" [queryParams]="{ engine: 'nats' }" data-testid="svc-new-nats"><span class="o-sq" style="background:var(--o-ok)"></span><div class="o-col" style="gap:2px"><b>NATS with JetStream</b><span class="o-faint o-sm">Streams, consumers, KV buckets · 3-node cluster</span></div><span class="o-r o-faint o-sm">Create →</span></a>
|
|
399
|
+
<a class="o-card o-cb o-flex" [routerLink]="host.link('services/new')" [queryParams]="{ engine: 'rabbitmq' }" data-testid="svc-new-rabbitmq"><span class="o-sq" style="background:var(--o-run)"></span><div class="o-col" style="gap:2px"><b>RabbitMQ</b><span class="o-faint o-sm">Quorum queues · inspected in the console</span></div><span class="o-r o-faint o-sm">Create →</span></a>
|
|
359
400
|
</div>
|
|
360
401
|
<span class="o-faint o-sm">Lifecycle states: {{ lifecycle }}. Open-source engines only.</span>
|
|
361
402
|
</div>
|
|
@@ -404,8 +445,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
404
445
|
</div>
|
|
405
446
|
</div>
|
|
406
447
|
<div class="o-g2">
|
|
407
|
-
<
|
|
408
|
-
<
|
|
448
|
+
<a class="o-card o-cb o-flex" [routerLink]="host.link('services/new')" [queryParams]="{ engine: 'nats' }" data-testid="svc-new-nats"><span class="o-sq" style="background:var(--o-ok)"></span><div class="o-col" style="gap:2px"><b>NATS with JetStream</b><span class="o-faint o-sm">Streams, consumers, KV buckets · 3-node cluster</span></div><span class="o-r o-faint o-sm">Create →</span></a>
|
|
449
|
+
<a class="o-card o-cb o-flex" [routerLink]="host.link('services/new')" [queryParams]="{ engine: 'rabbitmq' }" data-testid="svc-new-rabbitmq"><span class="o-sq" style="background:var(--o-run)"></span><div class="o-col" style="gap:2px"><b>RabbitMQ</b><span class="o-faint o-sm">Quorum queues · inspected in the console</span></div><span class="o-r o-faint o-sm">Create →</span></a>
|
|
409
450
|
</div>
|
|
410
451
|
<span class="o-faint o-sm">Lifecycle states: {{ lifecycle }}. Open-source engines only.</span>
|
|
411
452
|
</div>
|
|
@@ -414,7 +455,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
414
455
|
}], ctorParameters: () => [] });
|
|
415
456
|
|
|
416
457
|
const NAME = /^[a-z]([-a-z0-9]{0,28}[a-z0-9])?$/;
|
|
417
|
-
const DEFAULT_NAME = { postgres: 'orders-db', mysql: 'ledger-mysql', valkey: 'cache' };
|
|
458
|
+
const DEFAULT_NAME = { postgres: 'orders-db', mysql: 'ledger-mysql', valkey: 'cache', nats: 'events', rabbitmq: 'jobs-mq' };
|
|
418
459
|
// /{org}/{project}/services/new (prototype "services_new"): engine and version, size and region,
|
|
419
460
|
// name and environment, with the summary and price beside it (SVC-02…05). Creating returns to the
|
|
420
461
|
// list, where the instance shows Provisioning until the cell reports it Running.
|
|
@@ -422,6 +463,7 @@ class ServicesNewPage {
|
|
|
422
463
|
constructor() {
|
|
423
464
|
this.host = inject(ServicesHost);
|
|
424
465
|
this.api = inject(ServicesApi);
|
|
466
|
+
this.route = inject(ActivatedRoute);
|
|
425
467
|
this.router = inject(Router);
|
|
426
468
|
this.steps = [{ n: 1, label: 'Engine and version' }, { n: 2, label: 'Size and region' }, { n: 3, label: 'Name and environment' }];
|
|
427
469
|
this.hours = Array.from({ length: 24 }, (_, i) => (i + 3) % 24);
|
|
@@ -440,6 +482,16 @@ class ServicesNewPage {
|
|
|
440
482
|
this.env = signal('production', ...(ngDevMode ? [{ debugName: "env" }] : /* istanbul ignore next */ []));
|
|
441
483
|
this.attach = signal('', ...(ngDevMode ? [{ debugName: "attach" }] : /* istanbul ignore next */ []));
|
|
442
484
|
this.hour = signal(3, ...(ngDevMode ? [{ debugName: "hour" }] : /* istanbul ignore next */ []));
|
|
485
|
+
this.ha = signal(false, ...(ngDevMode ? [{ debugName: "ha" }] : /* istanbul ignore next */ []));
|
|
486
|
+
this.nodes = computed(() => (!this.ha() ? 1 : this.engine()?.ha === 'cluster' ? 3 : this.engine()?.id === 'mysql' ? 3 : 2), ...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
|
|
487
|
+
this.haLabel = computed(() => {
|
|
488
|
+
const e = this.engine();
|
|
489
|
+
if (e?.ha === 'cluster')
|
|
490
|
+
return '3-node cluster';
|
|
491
|
+
if (e?.ha === 'standby')
|
|
492
|
+
return e.id === 'mysql' ? 'Standby with automatic failover (3 nodes: MOCO runs 1, 3 or 5)' : 'Standby node with automatic failover';
|
|
493
|
+
return `${e?.name ?? 'This engine'} runs one node for now`;
|
|
494
|
+
}, ...(ngDevMode ? [{ debugName: "haLabel" }] : /* istanbul ignore next */ []));
|
|
443
495
|
this.keep = signal(7, ...(ngDevMode ? [{ debugName: "keep" }] : /* istanbul ignore next */ []));
|
|
444
496
|
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
445
497
|
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
@@ -486,7 +538,8 @@ class ServicesNewPage {
|
|
|
486
538
|
this.catalog.set(catalog);
|
|
487
539
|
this.quota.set(list.quota ?? null);
|
|
488
540
|
this.apps.set(apps);
|
|
489
|
-
const
|
|
541
|
+
const want = this.route.snapshot.queryParamMap.get('engine');
|
|
542
|
+
const first = catalog.engines.find((e) => e.id === want && e.available) ?? catalog.engines.find((e) => e.available) ?? null;
|
|
490
543
|
if (first)
|
|
491
544
|
this.pickEngine(first);
|
|
492
545
|
this.plan.set(catalog.plans.find((p) => p.id === (list.quota?.onlyPlan || catalog.plans[0]?.id)) ?? catalog.plans[0] ?? null);
|
|
@@ -510,6 +563,8 @@ class ServicesNewPage {
|
|
|
510
563
|
this.error.set('');
|
|
511
564
|
const before = this.engine()?.id;
|
|
512
565
|
this.engine.set(e);
|
|
566
|
+
if (!e.ha)
|
|
567
|
+
this.ha.set(false);
|
|
513
568
|
this.version.set((e.versions.find((v) => v.default) ?? e.versions[0])?.id ?? '');
|
|
514
569
|
// Follow the engine with a fitting default name until the person types one.
|
|
515
570
|
if (!this.nameTouched() || this.name() === DEFAULT_NAME[before ?? ''])
|
|
@@ -544,7 +599,7 @@ class ServicesNewPage {
|
|
|
544
599
|
this.api
|
|
545
600
|
.create(this.key, {
|
|
546
601
|
name: this.name(), engine: e.id, version: this.version(), plan: p.id, environment: env, backupHour: this.hour(), retentionDays: this.keep(),
|
|
547
|
-
attach: attach ? { app: attach, environment: env || 'production' } : undefined,
|
|
602
|
+
attach: attach ? { app: attach, environment: env || 'production' } : undefined, standby: this.ha() && !!e.ha,
|
|
548
603
|
})
|
|
549
604
|
.subscribe({
|
|
550
605
|
next: (d) => {
|
|
@@ -606,7 +661,11 @@ class ServicesNewPage {
|
|
|
606
661
|
@if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
|
|
607
662
|
<div class="o-g2">
|
|
608
663
|
<div class="o-fld"><label>Region</label><input class="o-in" [value]="region()" readonly aria-label="Region" /></div>
|
|
609
|
-
<div class="o-fld"><label>High availability
|
|
664
|
+
<div class="o-fld"><label>High availability</label><div class="o-flex">
|
|
665
|
+
@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>}
|
|
666
|
+
@else {<button type="button" class="o-tog" disabled aria-label="High availability"></button>}
|
|
667
|
+
<span class="o-sm">{{ haLabel() }}</span></div>
|
|
668
|
+
@if (quota()?.onlyPlan && engine()?.ha) {<span class="o-hint">{{ quota()!.plan }} includes one node.</span>}</div>
|
|
610
669
|
</div>
|
|
611
670
|
}
|
|
612
671
|
@if (step() === 3) {
|
|
@@ -662,9 +721,10 @@ class ServicesNewPage {
|
|
|
662
721
|
<span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
|
|
663
722
|
<span class="o-k">Region</span><span>{{ region() }}</span>
|
|
664
723
|
<span class="o-k">Network</span><span>Private · TLS</span>
|
|
665
|
-
<span class="o-k">
|
|
724
|
+
@if (nodes() > 1) {<span class="o-k">Nodes</span><span data-testid="svc-nodes">{{ nodes() }} · {{ engine()?.ha === 'cluster' ? 'cluster' : 'automatic failover' }}</span>}
|
|
725
|
+
<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>
|
|
666
726
|
</div>
|
|
667
|
-
<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">{{ plan() ? price(plan()!.priceCents) + '/mo' : '' }}</b></div>
|
|
727
|
+
<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>
|
|
668
728
|
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
|
|
669
729
|
</div>
|
|
670
730
|
</div>
|
|
@@ -725,7 +785,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
725
785
|
@if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
|
|
726
786
|
<div class="o-g2">
|
|
727
787
|
<div class="o-fld"><label>Region</label><input class="o-in" [value]="region()" readonly aria-label="Region" /></div>
|
|
728
|
-
<div class="o-fld"><label>High availability
|
|
788
|
+
<div class="o-fld"><label>High availability</label><div class="o-flex">
|
|
789
|
+
@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>}
|
|
790
|
+
@else {<button type="button" class="o-tog" disabled aria-label="High availability"></button>}
|
|
791
|
+
<span class="o-sm">{{ haLabel() }}</span></div>
|
|
792
|
+
@if (quota()?.onlyPlan && engine()?.ha) {<span class="o-hint">{{ quota()!.plan }} includes one node.</span>}</div>
|
|
729
793
|
</div>
|
|
730
794
|
}
|
|
731
795
|
@if (step() === 3) {
|
|
@@ -781,9 +845,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
781
845
|
<span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
|
|
782
846
|
<span class="o-k">Region</span><span>{{ region() }}</span>
|
|
783
847
|
<span class="o-k">Network</span><span>Private · TLS</span>
|
|
784
|
-
<span class="o-k">
|
|
848
|
+
@if (nodes() > 1) {<span class="o-k">Nodes</span><span data-testid="svc-nodes">{{ nodes() }} · {{ engine()?.ha === 'cluster' ? 'cluster' : 'automatic failover' }}</span>}
|
|
849
|
+
<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>
|
|
785
850
|
</div>
|
|
786
|
-
<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">{{ plan() ? price(plan()!.priceCents) + '/mo' : '' }}</b></div>
|
|
851
|
+
<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>
|
|
787
852
|
<span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
|
|
788
853
|
</div>
|
|
789
854
|
</div>
|
|
@@ -792,6 +857,397 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
792
857
|
}]
|
|
793
858
|
}], ctorParameters: () => [] });
|
|
794
859
|
|
|
860
|
+
// The browser console (SVC-17; prototype "services" › Console): SQL for Postgres and MySQL, run
|
|
861
|
+
// as the owner in a read-only transaction unless "Allow writes" is on, with the 1,000-row limit
|
|
862
|
+
// and 5 s statement timeout; an inspector for Valkey keys, NATS streams and RabbitMQ queues.
|
|
863
|
+
// Everything run here is in the audit log.
|
|
864
|
+
class ServiceConsoleTab {
|
|
865
|
+
constructor() {
|
|
866
|
+
this.api = inject(ServicesApi);
|
|
867
|
+
this.d = input.required(...(ngDevMode ? [{ debugName: "d" }] : /* istanbul ignore next */ []));
|
|
868
|
+
this.projectKey = input.required(...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
|
|
869
|
+
this.kind = input('sql', ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
|
|
870
|
+
this.sql = computed(() => this.kind() === 'sql', ...(ngDevMode ? [{ debugName: "sql" }] : /* istanbul ignore next */ []));
|
|
871
|
+
this.running = computed(() => ['Running', 'Updating', 'Degraded'].includes(this.d().state), ...(ngDevMode ? [{ debugName: "running" }] : /* istanbul ignore next */ []));
|
|
872
|
+
this.runsAs = computed(() => this.result()?.runsAs || this.d().connection?.user || 'the owner', ...(ngDevMode ? [{ debugName: "runsAs" }] : /* istanbul ignore next */ []));
|
|
873
|
+
this.stmt = signal('SELECT now();', ...(ngDevMode ? [{ debugName: "stmt" }] : /* istanbul ignore next */ []));
|
|
874
|
+
this.db = signal('', ...(ngDevMode ? [{ debugName: "db" }] : /* istanbul ignore next */ []));
|
|
875
|
+
this.write = signal(false, ...(ngDevMode ? [{ debugName: "write" }] : /* istanbul ignore next */ []));
|
|
876
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
877
|
+
this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
878
|
+
this.result = signal(null, ...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
|
|
879
|
+
this.pattern = signal('', ...(ngDevMode ? [{ debugName: "pattern" }] : /* istanbul ignore next */ []));
|
|
880
|
+
this.list = signal(null, ...(ngDevMode ? [{ debugName: "list" }] : /* istanbul ignore next */ []));
|
|
881
|
+
this.last = '';
|
|
882
|
+
}
|
|
883
|
+
inspectorLine() {
|
|
884
|
+
return this.kind() === 'keys' ? 'Keys by SCAN, values up to 100 items' : this.kind() === 'streams' ? 'Streams, consumers and the last 20 messages' : 'Queues with depth, unacknowledged and consumers';
|
|
885
|
+
}
|
|
886
|
+
runSql() {
|
|
887
|
+
const statement = this.stmt().trim();
|
|
888
|
+
if (!statement || this.busy())
|
|
889
|
+
return;
|
|
890
|
+
this.busy.set(true);
|
|
891
|
+
this.error.set('');
|
|
892
|
+
this.api.query(this.projectKey(), this.d().id, { database: this.db() || this.d().databases[0]?.name, statement, write: this.write() }).subscribe({
|
|
893
|
+
next: (r) => {
|
|
894
|
+
this.busy.set(false);
|
|
895
|
+
this.result.set(r);
|
|
896
|
+
},
|
|
897
|
+
error: (e) => {
|
|
898
|
+
this.busy.set(false);
|
|
899
|
+
this.result.set(null);
|
|
900
|
+
this.error.set(apiMessage(e, 'The statement could not run.'));
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
resultLine(r) {
|
|
905
|
+
const n = r.rows.length;
|
|
906
|
+
const rows = r.columns.length ? `${n.toLocaleString()} row${n === 1 ? '' : 's'}${r.truncated ? ' (limit reached)' : ''}` : `${r.command || 'OK'}${r.rowsAffected ? ' · ' + r.rowsAffected + ' rows' : ''}`;
|
|
907
|
+
return `${rows} · ${r.ms} ms · ${r.readOnly ? 'read-only' : 'committed'}`;
|
|
908
|
+
}
|
|
909
|
+
inspect(cursor = '', item = '') {
|
|
910
|
+
if (this.busy())
|
|
911
|
+
return;
|
|
912
|
+
this.busy.set(true);
|
|
913
|
+
this.error.set('');
|
|
914
|
+
this.api.inspect(this.projectKey(), this.d().id, { pattern: this.pattern(), cursor, item }).subscribe({
|
|
915
|
+
next: (r) => {
|
|
916
|
+
this.busy.set(false);
|
|
917
|
+
if (item) {
|
|
918
|
+
this.list.update((l) => (l ? { ...l, detail: r.detail } : r));
|
|
919
|
+
}
|
|
920
|
+
else if (cursor) {
|
|
921
|
+
this.list.update((l) => ({ ...r, items: [...(l?.items ?? []), ...r.items] }));
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
this.list.set(r);
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
error: (e) => {
|
|
928
|
+
this.busy.set(false);
|
|
929
|
+
this.error.set(apiMessage(e, 'The inspector could not read the service.'));
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
open(it) {
|
|
934
|
+
const k = this.kind();
|
|
935
|
+
this.last = k === 'keys' ? it['key'] : k === 'streams' ? it['name'] : `${it['vhost'] ?? '/'}/${it['name']}`;
|
|
936
|
+
this.inspect('', this.last);
|
|
937
|
+
}
|
|
938
|
+
label(it) {
|
|
939
|
+
return String(this.kind() === 'keys' ? it['key'] : it['name']);
|
|
940
|
+
}
|
|
941
|
+
note(it) {
|
|
942
|
+
switch (this.kind()) {
|
|
943
|
+
case 'keys':
|
|
944
|
+
return `${it['type']}${it['ttl'] > 0 ? ' · ttl ' + it['ttl'] + ' s' : ''}`;
|
|
945
|
+
case 'streams':
|
|
946
|
+
return `${Number(it['messages'] ?? 0).toLocaleString()} msgs · ${it['consumers'] ?? 0} consumers${it['kv'] ? ' · KV' : ''}`;
|
|
947
|
+
}
|
|
948
|
+
return `${it['vhost'] ?? '/'} · ${Number(it['messages'] ?? 0).toLocaleString()} msgs · ${it['messages_unacknowledged'] ?? 0} unacked`;
|
|
949
|
+
}
|
|
950
|
+
pretty(v) {
|
|
951
|
+
return JSON.stringify(v, null, 2);
|
|
952
|
+
}
|
|
953
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceConsoleTab, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
954
|
+
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: `
|
|
955
|
+
@if (sql()) {
|
|
956
|
+
<div class="o-flex o-wrap">
|
|
957
|
+
<span class="o-chip" [class.o-grn]="!write()" [class.o-amb]="write()" data-testid="svc-console-mode">{{ write() ? 'Read-write' : 'Read-only' }}</span>
|
|
958
|
+
<span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as {{ runsAs() }} · audited</span>
|
|
959
|
+
<span class="o-r"></span>
|
|
960
|
+
@if (d().canManage) {<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>}
|
|
961
|
+
@if (d().databases.length) {
|
|
962
|
+
<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">
|
|
963
|
+
@for (x of d().databases; track x.name) {<option [value]="x.name">{{ x.name }}</option>}
|
|
964
|
+
</select>
|
|
965
|
+
}
|
|
966
|
+
</div>
|
|
967
|
+
<div class="o-card">
|
|
968
|
+
<textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" aria-label="Query" [ngModel]="stmt()" (ngModelChange)="stmt.set($event)" (keydown.meta.enter)="runSql()" (keydown.control.enter)="runSql()" data-testid="svc-console-sql"></textarea>
|
|
969
|
+
<div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" (click)="runSql()" [disabled]="busy() || !stmt().trim() || !running()" data-testid="svc-console-run">Run</button></div>
|
|
970
|
+
</div>
|
|
971
|
+
@if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-console-error">{{ error() }}</div>}
|
|
972
|
+
@if (result(); as r) {
|
|
973
|
+
<div class="o-card" style="overflow-x:auto" data-testid="svc-console-result">
|
|
974
|
+
@if (r.columns.length) {
|
|
975
|
+
<div class="o-tbl"><div class="o-thead"><div class="o-tr">@for (c of r.columns; track $index) {<div class="o-th">{{ c }}</div>}</div></div><div class="o-tbody">
|
|
976
|
+
@for (row of r.rows; track $index) {<div class="o-tr">@for (v of row; track $index) {<div class="o-td o-mono o-sm" [class.o-faint]="v === 'NULL'">{{ v }}</div>}</div>}
|
|
977
|
+
</div></div>
|
|
978
|
+
}
|
|
979
|
+
<div class="o-cf">{{ resultLine(r) }}</div>
|
|
980
|
+
</div>
|
|
981
|
+
}
|
|
982
|
+
} @else {
|
|
983
|
+
<div class="o-flex o-wrap">
|
|
984
|
+
<span class="o-chip o-grn">Read-only</span>
|
|
985
|
+
<span class="o-faint o-sm">{{ inspectorLine() }} · audited</span>
|
|
986
|
+
<span class="o-r"></span>
|
|
987
|
+
@if (kind() !== 'streams') {<input class="o-in o-sm o-mono" style="width:200px" [placeholder]="kind() === 'keys' ? 'pattern, e.g. session:*' : 'filter by name'" aria-label="Filter" [ngModel]="pattern()" (ngModelChange)="pattern.set($event)" (keydown.enter)="inspect()" data-testid="svc-inspect-pattern" />}
|
|
988
|
+
<button type="button" class="o-btn o-sm o-dark" (click)="inspect()" [disabled]="busy() || !running()" data-testid="svc-inspect-run">{{ list() ? 'Refresh' : 'Inspect' }}</button>
|
|
989
|
+
</div>
|
|
990
|
+
@if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-console-error">{{ error() }}</div>}
|
|
991
|
+
@if (list(); as l) {
|
|
992
|
+
<div class="o-split o-w340">
|
|
993
|
+
<div class="o-card" data-testid="svc-inspect-list">
|
|
994
|
+
@for (it of l.items; track $index) {
|
|
995
|
+
<button type="button" class="o-row o-click" style="width:100%;text-align:left;border:none;background:none" (click)="open(it)">
|
|
996
|
+
<span class="o-mono o-b5 o-ell">{{ label(it) }}</span><span class="o-r o-faint o-sm">{{ note(it) }}</span>
|
|
997
|
+
</button>
|
|
998
|
+
} @empty {<div class="o-empty">{{ kind() === 'keys' ? 'No keys match.' : kind() === 'streams' ? 'No streams yet.' : 'No queues yet.' }}</div>}
|
|
999
|
+
@if (l.cursor) {<div class="o-cf"><button type="button" class="o-lnk" (click)="inspect(l.cursor)">More</button></div>}
|
|
1000
|
+
</div>
|
|
1001
|
+
<div class="o-card o-cb" data-testid="svc-inspect-detail">
|
|
1002
|
+
@if (l.detail; as det) {<pre class="o-mono o-sm" style="margin:0;white-space:pre-wrap;word-break:break-all">{{ pretty(det) }}</pre>}
|
|
1003
|
+
@else {<span class="o-faint o-sm">Pick {{ kind() === 'keys' ? 'a key' : kind() === 'streams' ? 'a stream' : 'a queue' }} to see {{ kind() === 'streams' ? 'its consumers and recent messages' : 'its contents' }}.</span>}
|
|
1004
|
+
</div>
|
|
1005
|
+
</div>
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
@if (!running()) {<span class="o-faint o-sm">The console opens once {{ d().name }} is running.</span>}
|
|
1009
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1010
|
+
}
|
|
1011
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceConsoleTab, decorators: [{
|
|
1012
|
+
type: Component,
|
|
1013
|
+
args: [{
|
|
1014
|
+
selector: 'o-svc-console',
|
|
1015
|
+
imports: [FormsModule],
|
|
1016
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1017
|
+
template: `
|
|
1018
|
+
@if (sql()) {
|
|
1019
|
+
<div class="o-flex o-wrap">
|
|
1020
|
+
<span class="o-chip" [class.o-grn]="!write()" [class.o-amb]="write()" data-testid="svc-console-mode">{{ write() ? 'Read-write' : 'Read-only' }}</span>
|
|
1021
|
+
<span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as {{ runsAs() }} · audited</span>
|
|
1022
|
+
<span class="o-r"></span>
|
|
1023
|
+
@if (d().canManage) {<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>}
|
|
1024
|
+
@if (d().databases.length) {
|
|
1025
|
+
<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">
|
|
1026
|
+
@for (x of d().databases; track x.name) {<option [value]="x.name">{{ x.name }}</option>}
|
|
1027
|
+
</select>
|
|
1028
|
+
}
|
|
1029
|
+
</div>
|
|
1030
|
+
<div class="o-card">
|
|
1031
|
+
<textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" aria-label="Query" [ngModel]="stmt()" (ngModelChange)="stmt.set($event)" (keydown.meta.enter)="runSql()" (keydown.control.enter)="runSql()" data-testid="svc-console-sql"></textarea>
|
|
1032
|
+
<div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" (click)="runSql()" [disabled]="busy() || !stmt().trim() || !running()" data-testid="svc-console-run">Run</button></div>
|
|
1033
|
+
</div>
|
|
1034
|
+
@if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-console-error">{{ error() }}</div>}
|
|
1035
|
+
@if (result(); as r) {
|
|
1036
|
+
<div class="o-card" style="overflow-x:auto" data-testid="svc-console-result">
|
|
1037
|
+
@if (r.columns.length) {
|
|
1038
|
+
<div class="o-tbl"><div class="o-thead"><div class="o-tr">@for (c of r.columns; track $index) {<div class="o-th">{{ c }}</div>}</div></div><div class="o-tbody">
|
|
1039
|
+
@for (row of r.rows; track $index) {<div class="o-tr">@for (v of row; track $index) {<div class="o-td o-mono o-sm" [class.o-faint]="v === 'NULL'">{{ v }}</div>}</div>}
|
|
1040
|
+
</div></div>
|
|
1041
|
+
}
|
|
1042
|
+
<div class="o-cf">{{ resultLine(r) }}</div>
|
|
1043
|
+
</div>
|
|
1044
|
+
}
|
|
1045
|
+
} @else {
|
|
1046
|
+
<div class="o-flex o-wrap">
|
|
1047
|
+
<span class="o-chip o-grn">Read-only</span>
|
|
1048
|
+
<span class="o-faint o-sm">{{ inspectorLine() }} · audited</span>
|
|
1049
|
+
<span class="o-r"></span>
|
|
1050
|
+
@if (kind() !== 'streams') {<input class="o-in o-sm o-mono" style="width:200px" [placeholder]="kind() === 'keys' ? 'pattern, e.g. session:*' : 'filter by name'" aria-label="Filter" [ngModel]="pattern()" (ngModelChange)="pattern.set($event)" (keydown.enter)="inspect()" data-testid="svc-inspect-pattern" />}
|
|
1051
|
+
<button type="button" class="o-btn o-sm o-dark" (click)="inspect()" [disabled]="busy() || !running()" data-testid="svc-inspect-run">{{ list() ? 'Refresh' : 'Inspect' }}</button>
|
|
1052
|
+
</div>
|
|
1053
|
+
@if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-console-error">{{ error() }}</div>}
|
|
1054
|
+
@if (list(); as l) {
|
|
1055
|
+
<div class="o-split o-w340">
|
|
1056
|
+
<div class="o-card" data-testid="svc-inspect-list">
|
|
1057
|
+
@for (it of l.items; track $index) {
|
|
1058
|
+
<button type="button" class="o-row o-click" style="width:100%;text-align:left;border:none;background:none" (click)="open(it)">
|
|
1059
|
+
<span class="o-mono o-b5 o-ell">{{ label(it) }}</span><span class="o-r o-faint o-sm">{{ note(it) }}</span>
|
|
1060
|
+
</button>
|
|
1061
|
+
} @empty {<div class="o-empty">{{ kind() === 'keys' ? 'No keys match.' : kind() === 'streams' ? 'No streams yet.' : 'No queues yet.' }}</div>}
|
|
1062
|
+
@if (l.cursor) {<div class="o-cf"><button type="button" class="o-lnk" (click)="inspect(l.cursor)">More</button></div>}
|
|
1063
|
+
</div>
|
|
1064
|
+
<div class="o-card o-cb" data-testid="svc-inspect-detail">
|
|
1065
|
+
@if (l.detail; as det) {<pre class="o-mono o-sm" style="margin:0;white-space:pre-wrap;word-break:break-all">{{ pretty(det) }}</pre>}
|
|
1066
|
+
@else {<span class="o-faint o-sm">Pick {{ kind() === 'keys' ? 'a key' : kind() === 'streams' ? 'a stream' : 'a queue' }} to see {{ kind() === 'streams' ? 'its consumers and recent messages' : 'its contents' }}.</span>}
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
@if (!running()) {<span class="o-faint o-sm">The console opens once {{ d().name }} is running.</span>}
|
|
1072
|
+
`,
|
|
1073
|
+
}]
|
|
1074
|
+
}], propDecorators: { d: [{ type: i0.Input, args: [{ isSignal: true, alias: "d", required: true }] }], projectKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "projectKey", required: true }] }], kind: [{ type: i0.Input, args: [{ isSignal: true, alias: "kind", required: false }] }] } });
|
|
1075
|
+
|
|
1076
|
+
// Backups › Fork and dumps (SVC-23, SVC-25): fork from the latest backup into another
|
|
1077
|
+
// environment; export a logical dump (pg_dump custom format, mysqldump SQL) and download it;
|
|
1078
|
+
// import a dump file or another server's database by connection string.
|
|
1079
|
+
class ServiceDumpsCard {
|
|
1080
|
+
constructor() {
|
|
1081
|
+
this.api = inject(ServicesApi);
|
|
1082
|
+
this.host = inject(ServicesHost);
|
|
1083
|
+
this.router = inject(Router);
|
|
1084
|
+
this.d = input.required(...(ngDevMode ? [{ debugName: "d" }] : /* istanbul ignore next */ []));
|
|
1085
|
+
this.projectKey = input.required(...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
|
|
1086
|
+
this.dumps = input(false, ...(ngDevMode ? [{ debugName: "dumps" }] : /* istanbul ignore next */ []));
|
|
1087
|
+
this.done = output();
|
|
1088
|
+
this.failed = output();
|
|
1089
|
+
this.envs = ['staging', 'development', 'production'];
|
|
1090
|
+
this.env = signal('staging', ...(ngDevMode ? [{ debugName: "env" }] : /* istanbul ignore next */ []));
|
|
1091
|
+
this.remote = signal('', ...(ngDevMode ? [{ debugName: "remote" }] : /* istanbul ignore next */ []));
|
|
1092
|
+
this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
1093
|
+
this.size = bytes;
|
|
1094
|
+
this.when = (s) => when(s);
|
|
1095
|
+
}
|
|
1096
|
+
hasBackup() {
|
|
1097
|
+
return (this.d().backups ?? []).some((b) => b.phase === 'completed');
|
|
1098
|
+
}
|
|
1099
|
+
tool() {
|
|
1100
|
+
return this.d().engine === 'mysql' ? 'mysqldump' : 'pg_dump';
|
|
1101
|
+
}
|
|
1102
|
+
dot(x) {
|
|
1103
|
+
return x.phase === 'failed' ? 'o-red' : x.phase === 'completed' ? 'o-grn' : 'o-blu';
|
|
1104
|
+
}
|
|
1105
|
+
go(obs, ok, fail) {
|
|
1106
|
+
this.busy.set(true);
|
|
1107
|
+
obs.subscribe({
|
|
1108
|
+
next: (v) => {
|
|
1109
|
+
this.busy.set(false);
|
|
1110
|
+
ok(v);
|
|
1111
|
+
},
|
|
1112
|
+
error: (e) => {
|
|
1113
|
+
this.busy.set(false);
|
|
1114
|
+
this.failed.emit(apiMessage(e, fail));
|
|
1115
|
+
},
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
fork() {
|
|
1119
|
+
this.go(this.api.fork(this.projectKey(), this.d().id, { environment: this.env() }), (f) => this.router.navigate(this.host.link('services/' + f.name)), 'The fork could not be started.');
|
|
1120
|
+
}
|
|
1121
|
+
exportDump() {
|
|
1122
|
+
this.go(this.api.exportDump(this.projectKey(), this.d().id), () => this.done.emit(`Exporting ${this.d().databases[0]?.name} with ${this.tool()}; it appears here to download when done.`), 'The export could not be started.');
|
|
1123
|
+
}
|
|
1124
|
+
importRemote() {
|
|
1125
|
+
this.go(this.api.importRemote(this.projectKey(), this.d().id, '', this.remote().trim()), (x) => {
|
|
1126
|
+
this.remote.set('');
|
|
1127
|
+
this.done.emit(`Importing ${x.database} from ${x.remote}.`);
|
|
1128
|
+
}, 'The import could not be started.');
|
|
1129
|
+
}
|
|
1130
|
+
upload(ev) {
|
|
1131
|
+
const el = ev.target;
|
|
1132
|
+
const file = el.files?.[0];
|
|
1133
|
+
el.value = '';
|
|
1134
|
+
if (!file)
|
|
1135
|
+
return;
|
|
1136
|
+
this.go(this.api.importUpload(this.projectKey(), this.d().id, '', file), (x) => this.done.emit(`${file.name} uploaded; importing into ${x.database}.`), 'The dump could not be uploaded.');
|
|
1137
|
+
}
|
|
1138
|
+
download(x) {
|
|
1139
|
+
this.go(this.api.downloadDump(this.projectKey(), this.d().id, x.id), (blob) => {
|
|
1140
|
+
const a = document.createElement('a');
|
|
1141
|
+
a.href = URL.createObjectURL(blob);
|
|
1142
|
+
a.download = `${this.d().name}-${x.database}-${x.id}${this.d().engine === 'mysql' ? '.sql' : '.dump'}`;
|
|
1143
|
+
a.click();
|
|
1144
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
1145
|
+
this.done.emit('Download recorded in the audit log.');
|
|
1146
|
+
}, 'The dump could not be downloaded.');
|
|
1147
|
+
}
|
|
1148
|
+
remove(x) {
|
|
1149
|
+
this.go(this.api.deleteDump(this.projectKey(), this.d().id, x.id), () => this.done.emit('Dump deleted.'), 'The dump could not be deleted.');
|
|
1150
|
+
}
|
|
1151
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceDumpsCard, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1152
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceDumpsCard, isStandalone: true, selector: "o-svc-dumps", inputs: { d: { classPropertyName: "d", publicName: "d", isSignal: true, isRequired: true, transformFunction: null }, projectKey: { classPropertyName: "projectKey", publicName: "projectKey", isSignal: true, isRequired: true, transformFunction: null }, dumps: { classPropertyName: "dumps", publicName: "dumps", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { done: "done", failed: "failed" }, host: { styleAttribute: "display:flex;flex-direction:column;gap:14px" }, ngImport: i0, template: `
|
|
1153
|
+
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-fork-card">
|
|
1154
|
+
<b>Fork for staging</b>
|
|
1155
|
+
<span class="o-hint">Copy from the latest backup into a new instance. {{ d().name }} keeps running untouched.</span>
|
|
1156
|
+
@if (d().canManage) {
|
|
1157
|
+
<div class="o-flex o-wrap">
|
|
1158
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-fork-env">
|
|
1159
|
+
@for (e of envs; track e) {<option [value]="e">{{ e }}</option>}
|
|
1160
|
+
</select>
|
|
1161
|
+
<button type="button" class="o-btn o-sm o-r" (click)="fork()" [disabled]="busy() || !hasBackup()" data-testid="svc-fork">Fork</button>
|
|
1162
|
+
</div>
|
|
1163
|
+
@if (!hasBackup()) {<span class="o-faint o-xs">Available after the first completed backup.</span>}
|
|
1164
|
+
}
|
|
1165
|
+
</div>
|
|
1166
|
+
@if (dumps()) {
|
|
1167
|
+
<div class="o-card" data-testid="svc-dumps">
|
|
1168
|
+
<div class="o-ch"><b>Dumps</b>@if (d().canManage) {<button type="button" class="o-btn o-xs o-r" (click)="exportDump()" [disabled]="busy() || d().state !== 'Running'" data-testid="svc-export">{{ tool() }}</button>}</div>
|
|
1169
|
+
@for (x of d().dumps; track x.id) {
|
|
1170
|
+
<div class="o-row" [attr.data-testid]="'svc-dump-' + x.id">
|
|
1171
|
+
<span [class]="'o-dot ' + dot(x)"></span>
|
|
1172
|
+
<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>
|
|
1173
|
+
<span class="o-faint o-xs">{{ x.phase === 'completed' ? (x.bytes ? size(x.bytes) + ' · ' : '') + when(x.stoppedAt || x.createdAt) : x.phase }}</span>
|
|
1174
|
+
@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>}
|
|
1175
|
+
@if (d().canManage && x.phase !== 'running') {<button type="button" class="o-lnk o-faint" (click)="remove(x)" [attr.aria-label]="'Delete dump ' + x.id" [disabled]="busy()">×</button>}
|
|
1176
|
+
</div>
|
|
1177
|
+
@if (x.error) {<div class="o-row o-sm" style="color:var(--o-bad-tx)">{{ x.error }}</div>}
|
|
1178
|
+
} @empty {<div class="o-empty">No dumps yet. Export one to download it, or import a dump into {{ d().name }}.</div>}
|
|
1179
|
+
@if (d().canManage) {
|
|
1180
|
+
<div class="o-cb o-col" style="gap:8px;border-top:1px solid var(--o-line-2)">
|
|
1181
|
+
<b class="o-sm">Import</b>
|
|
1182
|
+
<div class="o-flex o-wrap">
|
|
1183
|
+
<label class="o-btn o-sm" style="cursor:pointer">Upload a dump…<input type="file" style="display:none" (change)="upload($event)" data-testid="svc-import-file" /></label>
|
|
1184
|
+
<span class="o-faint o-xs">{{ d().engine === 'mysql' ? 'SQL' : 'pg_dump custom format or SQL' }}, gzipped or not, up to 5 GB</span>
|
|
1185
|
+
</div>
|
|
1186
|
+
<div class="o-flex o-wrap">
|
|
1187
|
+
<input class="o-in o-sm o-mono" style="flex:1;min-width:220px" [placeholder]="d().engine === 'mysql' ? 'mysql://user:password@host:3306/db' : 'postgresql://user:password@host:5432/db'" aria-label="Connection string" [ngModel]="remote()" (ngModelChange)="remote.set($event)" data-testid="svc-import-remote" />
|
|
1188
|
+
<button type="button" class="o-btn o-sm" (click)="importRemote()" [disabled]="busy() || !remote().trim() || d().state !== 'Running'" data-testid="svc-import-go">Import</button>
|
|
1189
|
+
</div>
|
|
1190
|
+
<span class="o-faint o-xs">Loads into {{ d().databases[0]?.name }} as its owner. The connection string is sealed and only reaches the cell while the import runs.</span>
|
|
1191
|
+
</div>
|
|
1192
|
+
}
|
|
1193
|
+
</div>
|
|
1194
|
+
}
|
|
1195
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1196
|
+
}
|
|
1197
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceDumpsCard, decorators: [{
|
|
1198
|
+
type: Component,
|
|
1199
|
+
args: [{
|
|
1200
|
+
selector: 'o-svc-dumps',
|
|
1201
|
+
imports: [FormsModule],
|
|
1202
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1203
|
+
host: { style: 'display:flex;flex-direction:column;gap:14px' },
|
|
1204
|
+
template: `
|
|
1205
|
+
<div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-fork-card">
|
|
1206
|
+
<b>Fork for staging</b>
|
|
1207
|
+
<span class="o-hint">Copy from the latest backup into a new instance. {{ d().name }} keeps running untouched.</span>
|
|
1208
|
+
@if (d().canManage) {
|
|
1209
|
+
<div class="o-flex o-wrap">
|
|
1210
|
+
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-fork-env">
|
|
1211
|
+
@for (e of envs; track e) {<option [value]="e">{{ e }}</option>}
|
|
1212
|
+
</select>
|
|
1213
|
+
<button type="button" class="o-btn o-sm o-r" (click)="fork()" [disabled]="busy() || !hasBackup()" data-testid="svc-fork">Fork</button>
|
|
1214
|
+
</div>
|
|
1215
|
+
@if (!hasBackup()) {<span class="o-faint o-xs">Available after the first completed backup.</span>}
|
|
1216
|
+
}
|
|
1217
|
+
</div>
|
|
1218
|
+
@if (dumps()) {
|
|
1219
|
+
<div class="o-card" data-testid="svc-dumps">
|
|
1220
|
+
<div class="o-ch"><b>Dumps</b>@if (d().canManage) {<button type="button" class="o-btn o-xs o-r" (click)="exportDump()" [disabled]="busy() || d().state !== 'Running'" data-testid="svc-export">{{ tool() }}</button>}</div>
|
|
1221
|
+
@for (x of d().dumps; track x.id) {
|
|
1222
|
+
<div class="o-row" [attr.data-testid]="'svc-dump-' + x.id">
|
|
1223
|
+
<span [class]="'o-dot ' + dot(x)"></span>
|
|
1224
|
+
<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>
|
|
1225
|
+
<span class="o-faint o-xs">{{ x.phase === 'completed' ? (x.bytes ? size(x.bytes) + ' · ' : '') + when(x.stoppedAt || x.createdAt) : x.phase }}</span>
|
|
1226
|
+
@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>}
|
|
1227
|
+
@if (d().canManage && x.phase !== 'running') {<button type="button" class="o-lnk o-faint" (click)="remove(x)" [attr.aria-label]="'Delete dump ' + x.id" [disabled]="busy()">×</button>}
|
|
1228
|
+
</div>
|
|
1229
|
+
@if (x.error) {<div class="o-row o-sm" style="color:var(--o-bad-tx)">{{ x.error }}</div>}
|
|
1230
|
+
} @empty {<div class="o-empty">No dumps yet. Export one to download it, or import a dump into {{ d().name }}.</div>}
|
|
1231
|
+
@if (d().canManage) {
|
|
1232
|
+
<div class="o-cb o-col" style="gap:8px;border-top:1px solid var(--o-line-2)">
|
|
1233
|
+
<b class="o-sm">Import</b>
|
|
1234
|
+
<div class="o-flex o-wrap">
|
|
1235
|
+
<label class="o-btn o-sm" style="cursor:pointer">Upload a dump…<input type="file" style="display:none" (change)="upload($event)" data-testid="svc-import-file" /></label>
|
|
1236
|
+
<span class="o-faint o-xs">{{ d().engine === 'mysql' ? 'SQL' : 'pg_dump custom format or SQL' }}, gzipped or not, up to 5 GB</span>
|
|
1237
|
+
</div>
|
|
1238
|
+
<div class="o-flex o-wrap">
|
|
1239
|
+
<input class="o-in o-sm o-mono" style="flex:1;min-width:220px" [placeholder]="d().engine === 'mysql' ? 'mysql://user:password@host:3306/db' : 'postgresql://user:password@host:5432/db'" aria-label="Connection string" [ngModel]="remote()" (ngModelChange)="remote.set($event)" data-testid="svc-import-remote" />
|
|
1240
|
+
<button type="button" class="o-btn o-sm" (click)="importRemote()" [disabled]="busy() || !remote().trim() || d().state !== 'Running'" data-testid="svc-import-go">Import</button>
|
|
1241
|
+
</div>
|
|
1242
|
+
<span class="o-faint o-xs">Loads into {{ d().databases[0]?.name }} as its owner. The connection string is sealed and only reaches the cell while the import runs.</span>
|
|
1243
|
+
</div>
|
|
1244
|
+
}
|
|
1245
|
+
</div>
|
|
1246
|
+
}
|
|
1247
|
+
`,
|
|
1248
|
+
}]
|
|
1249
|
+
}], 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"] }] } });
|
|
1250
|
+
|
|
795
1251
|
const COLOUR = { ERROR: 'var(--o-bad-solid)', FATAL: 'var(--o-bad-solid)', PANIC: 'var(--o-bad-solid)', WARNING: 'var(--o-run)' };
|
|
796
1252
|
// The instance page's Logs tab (prototype "svc · logs"): the engine's own log from its pods —
|
|
797
1253
|
// all, errors, the slow log, connections — newest first, refreshed every 10 s.
|
|
@@ -1074,16 +1530,11 @@ const TABS = [
|
|
|
1074
1530
|
{ id: 'overview', label: 'Overview' }, { id: 'users', label: 'Databases and users' }, { id: 'backups', label: 'Backups' }, { id: 'metrics', label: 'Metrics' },
|
|
1075
1531
|
{ id: 'logs', label: 'Logs' }, { id: 'console', label: 'Console' }, { id: 'settings', label: 'Settings' },
|
|
1076
1532
|
];
|
|
1077
|
-
/** Engine parameters the prototype lists (validated subset, GA). */
|
|
1078
|
-
const PARAMS = {
|
|
1079
|
-
postgres: [['max_connections', '100', 'restart'], ['work_mem', '4MB', 'live'], ['log_min_duration_statement', '250ms', 'live']],
|
|
1080
|
-
mysql: [['max_connections', '151', 'restart'], ['long_query_time', '0.25', 'live'], ['innodb_buffer_pool_size', 'auto', 'restart']],
|
|
1081
|
-
valkey: [['maxmemory-policy', 'allkeys-lru', 'live'], ['timeout', '0', 'live'], ['slowlog-log-slower-than', '10000', 'live']],
|
|
1082
|
-
};
|
|
1083
1533
|
// /{org}/{project}/services/:id (prototype "services"): one instance — its connection details and
|
|
1084
1534
|
// reveal, attached apps, databases and users, backups and point-in-time restore, metrics and engine
|
|
1085
|
-
// logs read from the cell,
|
|
1086
|
-
//
|
|
1535
|
+
// logs read from the cell, the console (SQL, or the key, stream and queue inspector), and settings
|
|
1536
|
+
// (resize, high availability, networking, maintenance, engine parameters, delete). Services v1
|
|
1537
|
+
// (P5-41): NATS streams and RabbitMQ vhosts, per-app users, fork and dumps.
|
|
1087
1538
|
class ServicePage {
|
|
1088
1539
|
constructor() {
|
|
1089
1540
|
this.host = inject(ServicesHost);
|
|
@@ -1119,6 +1570,13 @@ class ServicePage {
|
|
|
1119
1570
|
this.attApp = signal('', ...(ngDevMode ? [{ debugName: "attApp" }] : /* istanbul ignore next */ []));
|
|
1120
1571
|
this.attEnv = signal('production', ...(ngDevMode ? [{ debugName: "attEnv" }] : /* istanbul ignore next */ []));
|
|
1121
1572
|
this.attPrefix = signal('', ...(ngDevMode ? [{ debugName: "attPrefix" }] : /* istanbul ignore next */ []));
|
|
1573
|
+
this.attOwn = signal(false, ...(ngDevMode ? [{ debugName: "attOwn" }] : /* istanbul ignore next */ []));
|
|
1574
|
+
this.attVhost = signal('/', ...(ngDevMode ? [{ debugName: "attVhost" }] : /* istanbul ignore next */ []));
|
|
1575
|
+
this.subjects = signal('', ...(ngDevMode ? [{ debugName: "subjects" }] : /* istanbul ignore next */ []));
|
|
1576
|
+
this.maxAge = signal(168, ...(ngDevMode ? [{ debugName: "maxAge" }] : /* istanbul ignore next */ []));
|
|
1577
|
+
this.streamAges = [{ h: 24, label: 'keep 1 day' }, { h: 168, label: 'keep 7 days' }, { h: 720, label: 'keep 30 days' }, { h: 0, label: 'until limits' }];
|
|
1578
|
+
this.paramDraft = signal({}, ...(ngDevMode ? [{ debugName: "paramDraft" }] : /* istanbul ignore next */ []));
|
|
1579
|
+
this.paramsChanged = computed(() => Object.keys(this.paramDraft()).length > 0, ...(ngDevMode ? [{ debugName: "paramsChanged" }] : /* istanbul ignore next */ []));
|
|
1122
1580
|
this.addingDb = signal(false, ...(ngDevMode ? [{ debugName: "addingDb" }] : /* istanbul ignore next */ []));
|
|
1123
1581
|
this.dbName = signal('', ...(ngDevMode ? [{ debugName: "dbName" }] : /* istanbul ignore next */ []));
|
|
1124
1582
|
this.addingUser = signal(false, ...(ngDevMode ? [{ debugName: "addingUser" }] : /* istanbul ignore next */ []));
|
|
@@ -1134,11 +1592,10 @@ class ServicePage {
|
|
|
1134
1592
|
this.envVar = computed(() => this.engine()?.envVar ?? 'DATABASE_URL', ...(ngDevMode ? [{ debugName: "envVar" }] : /* istanbul ignore next */ []));
|
|
1135
1593
|
this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
|
|
1136
1594
|
this.region = computed(() => this.host.project()?.region || 'your region', ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
|
|
1137
|
-
this.params = computed(() => PARAMS[this.d()?.engine ?? ''] ?? [], ...(ngDevMode ? [{ debugName: "params" }] : /* istanbul ignore next */ []));
|
|
1138
1595
|
this.conn = computed(() => this.revealed()?.connection ?? this.d()?.connection ?? null, ...(ngDevMode ? [{ debugName: "conn" }] : /* istanbul ignore next */ []));
|
|
1139
1596
|
this.snippetKeys = computed(() => {
|
|
1140
1597
|
const s = this.conn()?.snippets ?? {};
|
|
1141
|
-
return ['uri', 'psql', 'mysql', 'valkey-cli', 'node'].filter((k) => k in s);
|
|
1598
|
+
return ['uri', 'psql', 'mysql', 'valkey-cli', 'nats', 'node', 'cli'].filter((k) => k in s);
|
|
1142
1599
|
}, ...(ngDevMode ? [{ debugName: "snippetKeys" }] : /* istanbul ignore next */ []));
|
|
1143
1600
|
this.backups = computed(() => [...(this.d()?.backups ?? [])].sort((a, b) => (b.startedAt ?? '9').localeCompare(a.startedAt ?? '9')), ...(ngDevMode ? [{ debugName: "backups" }] : /* istanbul ignore next */ []));
|
|
1144
1601
|
this.attEnvs = computed(() => {
|
|
@@ -1197,6 +1654,11 @@ class ServicePage {
|
|
|
1197
1654
|
const q = fig('queries');
|
|
1198
1655
|
out.push({ label: 'Queries / s', value: q?.rate != null ? num(q.rate, 1) : '–', sub: `slow ${num(fig('slow_queries')?.value)} since start` });
|
|
1199
1656
|
}
|
|
1657
|
+
else if (d?.engine === 'nats' || d?.engine === 'rabbitmq') {
|
|
1658
|
+
const pub = fig('published');
|
|
1659
|
+
const depth = fig(d.engine === 'nats' ? 'stream_depth' : 'queue_depth');
|
|
1660
|
+
out.push({ label: 'Published / s', value: pub?.rate != null ? num(pub.rate, 1) : '–', sub: `${num(depth?.value)} ${d.engine === 'nats' ? 'in streams' : 'ready'} · ${num(fig('unacked')?.value)} unacked` });
|
|
1661
|
+
}
|
|
1200
1662
|
else {
|
|
1201
1663
|
out.push({ label: 'Hit ratio', value: fig('hit_ratio') ? pct(fig('hit_ratio').value) : '–', sub: `${num(fig('keys')?.value)} keys · ${bytes(fig('memory_used')?.value)}` });
|
|
1202
1664
|
}
|
|
@@ -1266,7 +1728,7 @@ class ServicePage {
|
|
|
1266
1728
|
if (d.window?.from && !this.pitr())
|
|
1267
1729
|
this.pitr.set(toLocalInput(new Date().toISOString()));
|
|
1268
1730
|
}
|
|
1269
|
-
const settling = moving(d.state) || !!this.openRotation() || d.attachments.some((a) => a.state === 'pending') || (d.backups ?? []).some((b) => b.phase === 'running' || b.phase === 'pending');
|
|
1731
|
+
const settling = moving(d.state) || d.readyNodes < d.nodes || (d.dumps ?? []).some((x) => x.phase !== 'completed' && x.phase !== 'failed') || !!this.openRotation() || d.attachments.some((a) => a.state === 'pending') || (d.backups ?? []).some((b) => b.phase === 'running' || b.phase === 'pending');
|
|
1270
1732
|
this.loadTimer = setTimeout(() => this.load(), settling ? 4000 : 30_000);
|
|
1271
1733
|
},
|
|
1272
1734
|
error: (e) => {
|
|
@@ -1294,7 +1756,7 @@ class ServicePage {
|
|
|
1294
1756
|
return `${cpu} / ${ram} / ${d.diskGb} GB`;
|
|
1295
1757
|
}
|
|
1296
1758
|
snippetLabel(k) {
|
|
1297
|
-
return { uri: 'URI', node: 'Node' }[k] ?? k;
|
|
1759
|
+
return { uri: 'URI', node: 'Node', cli: 'origine CLI', nats: 'nats' }[k] ?? k;
|
|
1298
1760
|
}
|
|
1299
1761
|
attState(a) {
|
|
1300
1762
|
return a.state === 'live' ? 'Injected' : a.state === 'failed' ? 'Failed' : 'Redeploying…';
|
|
@@ -1364,9 +1826,10 @@ class ServicePage {
|
|
|
1364
1826
|
}
|
|
1365
1827
|
attach(d) {
|
|
1366
1828
|
const app = this.attApp().trim();
|
|
1367
|
-
|
|
1829
|
+
const body = { app, environment: this.attEnv(), prefix: this.attPrefix().trim(), ownUser: this.attOwn(), database: d.engine === 'rabbitmq' ? this.attVhost() : undefined };
|
|
1830
|
+
this.run(this.api.attach(this.key(), d.id, body), (a) => {
|
|
1368
1831
|
this.attachOpen.set(false);
|
|
1369
|
-
this.notice.set(`${d.name} attached to ${app} · ${this.attEnv()}: ${this.attPrefix()}${this.envVar()} is injected and ${app} redeploys.`);
|
|
1832
|
+
this.notice.set(`${d.name} attached to ${app} · ${this.attEnv()}: ${this.attPrefix()}${this.envVar()} is injected${a?.ownUser ? ' with its own user ' + a.user : ''} and ${app} redeploys.`);
|
|
1370
1833
|
}, 'The service could not be attached.');
|
|
1371
1834
|
}
|
|
1372
1835
|
detach(d, a) {
|
|
@@ -1476,6 +1939,70 @@ class ServicePage {
|
|
|
1476
1939
|
this.notice.set(`${d.name} is scheduled for deletion · undo within 24 hours.`);
|
|
1477
1940
|
}, 'The service could not be deleted.');
|
|
1478
1941
|
}
|
|
1942
|
+
// ---- v1 -------------------------------------------------------------------
|
|
1943
|
+
nodesPrice(d) {
|
|
1944
|
+
return money((this.plans().find((p) => p.id === d.plan)?.priceCents ?? 0) * d.nodes);
|
|
1945
|
+
}
|
|
1946
|
+
refresh() {
|
|
1947
|
+
this.load();
|
|
1948
|
+
}
|
|
1949
|
+
snapshotName(d) {
|
|
1950
|
+
return d.engine === 'nats' ? 'stream snapshot' : d.engine === 'rabbitmq' ? 'definitions export' : 'RDB snapshot';
|
|
1951
|
+
}
|
|
1952
|
+
/** MySQL replicas come in pairs. */
|
|
1953
|
+
step(d) {
|
|
1954
|
+
return d.engine === 'mysql' ? 2 : 1;
|
|
1955
|
+
}
|
|
1956
|
+
topology(d, standby, replicas) {
|
|
1957
|
+
const nodes = d.engine === 'nats' || d.engine === 'rabbitmq' ? (standby ? 3 : 1) : 1 + replicas + (standby ? (d.engine === 'mysql' ? 2 : 1) : 0);
|
|
1958
|
+
this.save(d, { standby, replicas: Math.max(0, replicas) }, `${d.name} now runs ${nodes} node${nodes === 1 ? '' : 's'}${standby ? ' with automatic failover' : ''}.`);
|
|
1959
|
+
}
|
|
1960
|
+
setParam(p, v) {
|
|
1961
|
+
this.paramDraft.update((m) => ({ ...m, [p.name]: v }));
|
|
1962
|
+
}
|
|
1963
|
+
paramNote(d) {
|
|
1964
|
+
const changed = Object.keys(this.paramDraft());
|
|
1965
|
+
const restarts = d.parameters.filter((p) => p.restart && changed.includes(p.name)).map((p) => p.name);
|
|
1966
|
+
if (!changed.length)
|
|
1967
|
+
return 'Empty fields keep the default (shown greyed).';
|
|
1968
|
+
return restarts.length ? `${restarts.join(', ')} restart${restarts.length === 1 ? 's' : ''} ${d.engineName}${d.nodes > 1 ? ' one node at a time' : ''}.` : 'Applied live.';
|
|
1969
|
+
}
|
|
1970
|
+
saveParams(d) {
|
|
1971
|
+
const all = {};
|
|
1972
|
+
for (const p of d.parameters) {
|
|
1973
|
+
const v = this.paramDraft()[p.name] ?? p.value ?? '';
|
|
1974
|
+
if (v.trim())
|
|
1975
|
+
all[p.name] = v.trim();
|
|
1976
|
+
}
|
|
1977
|
+
this.run(this.api.update(this.key(), d.id, { parameters: all }), () => {
|
|
1978
|
+
this.paramDraft.set({});
|
|
1979
|
+
this.notice.set('Parameters saved; the cell applies them now.');
|
|
1980
|
+
}, 'The parameters could not be saved.');
|
|
1981
|
+
}
|
|
1982
|
+
addStream(d) {
|
|
1983
|
+
const name = this.dbName().trim();
|
|
1984
|
+
const subjects = this.subjects().split(',').map((x) => x.trim()).filter(Boolean);
|
|
1985
|
+
this.run(this.api.addStream(this.key(), d.id, { name, subjects, maxAgeHours: this.maxAge() }), () => {
|
|
1986
|
+
this.addingDb.set(false);
|
|
1987
|
+
this.dbName.set('');
|
|
1988
|
+
this.subjects.set('');
|
|
1989
|
+
this.notice.set(`Stream ${name} created.`);
|
|
1990
|
+
}, 'The stream could not be created.');
|
|
1991
|
+
}
|
|
1992
|
+
removeStream(d, name) {
|
|
1993
|
+
this.run(this.api.removeStream(this.key(), d.id, name), () => this.notice.set(`Stream ${name} and its messages removed.`), 'The stream could not be removed.');
|
|
1994
|
+
}
|
|
1995
|
+
addVhost(d) {
|
|
1996
|
+
const name = this.dbName().trim();
|
|
1997
|
+
this.run(this.api.addVhost(this.key(), d.id, name), () => {
|
|
1998
|
+
this.addingDb.set(false);
|
|
1999
|
+
this.dbName.set('');
|
|
2000
|
+
this.notice.set(`Virtual host ${name} created.`);
|
|
2001
|
+
}, 'The virtual host could not be created.');
|
|
2002
|
+
}
|
|
2003
|
+
removeVhost(d, name) {
|
|
2004
|
+
this.run(this.api.removeVhost(this.key(), d.id, name), () => this.notice.set(`Virtual host ${name} removed.`), 'The virtual host could not be removed.');
|
|
2005
|
+
}
|
|
1479
2006
|
undelete(d) {
|
|
1480
2007
|
this.run(this.api.undelete(this.key(), d.id), () => this.notice.set(`${d.name} is back; reattach its apps if they need it.`), 'The delete could not be undone.');
|
|
1481
2008
|
}
|
|
@@ -1547,7 +2074,7 @@ class ServicePage {
|
|
|
1547
2074
|
<div class="o-ch"><b>Attached apps</b>@if (d.canManage && !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>
|
|
1548
2075
|
@for (a of d.attachments; track a.id) {
|
|
1549
2076
|
<div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
|
|
1550
|
-
<span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
|
|
2077
|
+
<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>}
|
|
1551
2078
|
<span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
|
|
1552
2079
|
@if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
|
|
1553
2080
|
</div>
|
|
@@ -1559,6 +2086,8 @@ class ServicePage {
|
|
|
1559
2086
|
} @else {<input class="o-in o-sm o-mono" style="width:120px" placeholder="app" aria-label="App" [ngModel]="attApp()" (ngModelChange)="attApp.set($event)" data-testid="svc-att-app" />}
|
|
1560
2087
|
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="attEnv()" (ngModelChange)="attEnv.set($event)" data-testid="svc-att-env">@for (e of attEnvs(); track e) {<option [value]="e">{{ e }}</option>}</select>
|
|
1561
2088
|
<input class="o-in o-sm o-mono" style="width:110px" placeholder="prefix (opt.)" aria-label="Prefix" [ngModel]="attPrefix()" (ngModelChange)="attPrefix.set($event)" />
|
|
2089
|
+
@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>}
|
|
2090
|
+
<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>
|
|
1562
2091
|
<span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
|
|
1563
2092
|
<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>
|
|
1564
2093
|
</div>
|
|
@@ -1577,6 +2106,32 @@ class ServicePage {
|
|
|
1577
2106
|
}
|
|
1578
2107
|
@case ('users') {
|
|
1579
2108
|
<div class="o-g2" style="align-items:start;gap:18px">
|
|
2109
|
+
@if (d.engine === 'nats') {
|
|
2110
|
+
<div class="o-card" data-testid="svc-streams">
|
|
2111
|
+
<div class="o-ch"><b>Streams</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-stream-add">+ Stream</button>}</div>
|
|
2112
|
+
@if (addingDb()) {
|
|
2113
|
+
<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>
|
|
2114
|
+
}
|
|
2115
|
+
@for (st of d.streams; track st.name) {
|
|
2116
|
+
<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>
|
|
2117
|
+
@if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="removeStream(d, st.name)" [attr.aria-label]="'Remove ' + st.name" [disabled]="busy()">×</button>}</div>
|
|
2118
|
+
} @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
|
|
2119
|
+
<div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
|
|
2120
|
+
</div>
|
|
2121
|
+
} @else if (d.engine === 'rabbitmq') {
|
|
2122
|
+
<div class="o-card" data-testid="svc-vhosts">
|
|
2123
|
+
<div class="o-ch"><b>Virtual hosts</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-vhost-add">+ Virtual host</button>}</div>
|
|
2124
|
+
@if (addingDb()) {
|
|
2125
|
+
<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>
|
|
2126
|
+
}
|
|
2127
|
+
<div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
|
|
2128
|
+
@for (v of d.vhosts ?? []; track v) {
|
|
2129
|
+
<div class="o-row"><span class="o-mono o-b5">{{ v }}</span><span class="o-r o-faint o-sm">quorum queues</span>
|
|
2130
|
+
@if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="removeVhost(d, v)" [attr.aria-label]="'Remove ' + v" [disabled]="busy()">×</button>}</div>
|
|
2131
|
+
}
|
|
2132
|
+
<div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
|
|
2133
|
+
</div>
|
|
2134
|
+
} @else {
|
|
1580
2135
|
<div class="o-card">
|
|
1581
2136
|
<div class="o-ch"><b>Databases</b>@if (d.canManage && engine()?.databases) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-db-add">+ Database</button>}</div>
|
|
1582
2137
|
@if (addingDb()) {
|
|
@@ -1587,6 +2142,7 @@ class ServicePage {
|
|
|
1587
2142
|
@if (d.canManage && !first) {<button type="button" class="o-lnk o-faint" (click)="removeDb(d, db.name)" [attr.aria-label]="'Remove ' + db.name" [disabled]="busy()">×</button>}</div>
|
|
1588
2143
|
} @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
|
|
1589
2144
|
</div>
|
|
2145
|
+
}
|
|
1590
2146
|
<div class="o-card">
|
|
1591
2147
|
<div class="o-ch"><b>Users</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingUser.set(!addingUser())" data-testid="svc-user-add">+ User</button>}</div>
|
|
1592
2148
|
@if (addingUser()) {
|
|
@@ -1601,7 +2157,7 @@ class ServicePage {
|
|
|
1601
2157
|
<span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
|
|
1602
2158
|
@if (d.canManage && 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>
|
|
1603
2159
|
}
|
|
1604
|
-
<div class="o-cf">Per-app users
|
|
2160
|
+
<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</div>
|
|
1605
2161
|
</div>
|
|
1606
2162
|
</div>
|
|
1607
2163
|
@if (d.engine === 'postgres') {
|
|
@@ -1631,7 +2187,7 @@ class ServicePage {
|
|
|
1631
2187
|
<button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!d.canManage || !pitr() || !d.window?.from" data-testid="svc-pitr-go">Restore…</button>
|
|
1632
2188
|
</div>
|
|
1633
2189
|
} @else {
|
|
1634
|
-
<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
|
|
2190
|
+
<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></div>
|
|
1635
2191
|
}
|
|
1636
2192
|
<div class="o-card o-cb o-form">
|
|
1637
2193
|
<b>Schedule</b>
|
|
@@ -1644,7 +2200,7 @@ class ServicePage {
|
|
|
1644
2200
|
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.canManage" [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>
|
|
1645
2201
|
</div>
|
|
1646
2202
|
</div>
|
|
1647
|
-
<
|
|
2203
|
+
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
1648
2204
|
</div>
|
|
1649
2205
|
</div>
|
|
1650
2206
|
}
|
|
@@ -1655,10 +2211,7 @@ class ServicePage {
|
|
|
1655
2211
|
<o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
|
|
1656
2212
|
}
|
|
1657
2213
|
@case ('console') {
|
|
1658
|
-
<
|
|
1659
|
-
@if (d.databases.length) {<select class="o-in o-sm" style="width:auto" aria-label="Database" disabled>@for (db of d.databases; track db.name) {<option>{{ db.name }}</option>}</select>}</div>
|
|
1660
|
-
<div class="o-card"><textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" disabled aria-label="Query">{{ d.engine === 'valkey' ? 'INFO keyspace' : 'SELECT * FROM … LIMIT 50' }}</textarea><div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" disabled>Run</button></div></div>
|
|
1661
|
-
<span class="o-faint o-sm">The console arrives at GA. Until then, connect with the snippets on Overview from an attached app, or reveal the password.</span>
|
|
2214
|
+
<o-svc-console [d]="d" [projectKey]="key()" [kind]="engine()?.console ?? 'sql'" />
|
|
1662
2215
|
}
|
|
1663
2216
|
@case ('settings') {
|
|
1664
2217
|
<div class="o-col" style="gap:14px;max-width:820px">
|
|
@@ -1673,7 +2226,28 @@ class ServicePage {
|
|
|
1673
2226
|
</div>
|
|
1674
2227
|
<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>
|
|
1675
2228
|
</div>
|
|
1676
|
-
<div class="o-card"
|
|
2229
|
+
<div class="o-card" data-testid="svc-ha">
|
|
2230
|
+
<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>
|
|
2231
|
+
@switch (engine()?.ha) {
|
|
2232
|
+
@case ('standby') {
|
|
2233
|
+
<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.canManage || busy()" (click)="topology(d, !d.standby, d.standby ? 0 : d.replicas)" aria-label="Standby" data-testid="svc-standby"></button></div>
|
|
2234
|
+
<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>
|
|
2235
|
+
@if (d.canManage) {
|
|
2236
|
+
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2237
|
+
<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>
|
|
2238
|
+
}</div>
|
|
2239
|
+
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2240
|
+
}
|
|
2241
|
+
@case ('cluster') {
|
|
2242
|
+
<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.canManage || busy()" (click)="topology(d, !d.standby, 0)" aria-label="3-node cluster" data-testid="svc-standby"></button></div>
|
|
2243
|
+
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2244
|
+
}
|
|
2245
|
+
@default {
|
|
2246
|
+
<div class="o-row"><span style="flex:1">Replica with failover</span><b class="o-ph-tag o-later">Later</b></div>
|
|
2247
|
+
<div class="o-cf">{{ d.engineName }} runs one node for now; daily snapshots keep its data.</div>
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
</div>
|
|
1677
2251
|
<div class="o-card">
|
|
1678
2252
|
<div class="o-ch"><b>Networking</b></div>
|
|
1679
2253
|
<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>
|
|
@@ -1694,9 +2268,16 @@ class ServicePage {
|
|
|
1694
2268
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
1695
2269
|
</div>
|
|
1696
2270
|
</div>
|
|
1697
|
-
<div class="o-card"><div class="o-ch"><b>Engine parameters</b><
|
|
1698
|
-
@for (p of
|
|
1699
|
-
|
|
2271
|
+
<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">
|
|
2272
|
+
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2273
|
+
<div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
|
|
2274
|
+
<div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [placeholder]="p.default" [disabled]="!d.canManage" [attr.aria-label]="p.name" [ngModel]="paramDraft()[p.name] ?? p.value ?? ''" (ngModelChange)="setParam(p, $event)" [attr.data-testid]="'svc-param-' + p.name" /></div>
|
|
2275
|
+
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2276
|
+
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2277
|
+
</div>}
|
|
2278
|
+
</div></div>
|
|
2279
|
+
@if (d.canManage) {<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>}
|
|
2280
|
+
</div>
|
|
1700
2281
|
@if (d.canManage && !d.deletedAt) {
|
|
1701
2282
|
<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>
|
|
1702
2283
|
}
|
|
@@ -1741,13 +2322,13 @@ class ServicePage {
|
|
|
1741
2322
|
} @else {
|
|
1742
2323
|
<div class="o-page">@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>} @else {<div class="o-empty">Loading…</div>}</div>
|
|
1743
2324
|
}
|
|
1744
|
-
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ServiceMetricsTab, selector: "o-svc-metrics", inputs: ["readings", "slowLines", "engine", "projectKey", "service", "dashboard"] }, { kind: "component", type: ServiceLogsTab, selector: "o-svc-logs", inputs: ["projectKey", "id", "service", "environment", "debugLogs"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
2325
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ServiceMetricsTab, selector: "o-svc-metrics", inputs: ["readings", "slowLines", "engine", "projectKey", "service", "dashboard"] }, { kind: "component", type: ServiceLogsTab, selector: "o-svc-logs", inputs: ["projectKey", "id", "service", "environment", "debugLogs"] }, { kind: "component", type: ServiceConsoleTab, selector: "o-svc-console", inputs: ["d", "projectKey", "kind"] }, { kind: "component", type: ServiceDumpsCard, selector: "o-svc-dumps", inputs: ["d", "projectKey", "dumps"], outputs: ["done", "failed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1745
2326
|
}
|
|
1746
2327
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicePage, decorators: [{
|
|
1747
2328
|
type: Component,
|
|
1748
2329
|
args: [{
|
|
1749
2330
|
selector: 'o-service',
|
|
1750
|
-
imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab],
|
|
2331
|
+
imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab, ServiceConsoleTab, ServiceDumpsCard],
|
|
1751
2332
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1752
2333
|
template: `
|
|
1753
2334
|
@if (d(); as d) {
|
|
@@ -1816,7 +2397,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1816
2397
|
<div class="o-ch"><b>Attached apps</b>@if (d.canManage && !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>
|
|
1817
2398
|
@for (a of d.attachments; track a.id) {
|
|
1818
2399
|
<div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
|
|
1819
|
-
<span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
|
|
2400
|
+
<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>}
|
|
1820
2401
|
<span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
|
|
1821
2402
|
@if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
|
|
1822
2403
|
</div>
|
|
@@ -1828,6 +2409,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1828
2409
|
} @else {<input class="o-in o-sm o-mono" style="width:120px" placeholder="app" aria-label="App" [ngModel]="attApp()" (ngModelChange)="attApp.set($event)" data-testid="svc-att-app" />}
|
|
1829
2410
|
<select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="attEnv()" (ngModelChange)="attEnv.set($event)" data-testid="svc-att-env">@for (e of attEnvs(); track e) {<option [value]="e">{{ e }}</option>}</select>
|
|
1830
2411
|
<input class="o-in o-sm o-mono" style="width:110px" placeholder="prefix (opt.)" aria-label="Prefix" [ngModel]="attPrefix()" (ngModelChange)="attPrefix.set($event)" />
|
|
2412
|
+
@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>}
|
|
2413
|
+
<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>
|
|
1831
2414
|
<span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
|
|
1832
2415
|
<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>
|
|
1833
2416
|
</div>
|
|
@@ -1846,6 +2429,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1846
2429
|
}
|
|
1847
2430
|
@case ('users') {
|
|
1848
2431
|
<div class="o-g2" style="align-items:start;gap:18px">
|
|
2432
|
+
@if (d.engine === 'nats') {
|
|
2433
|
+
<div class="o-card" data-testid="svc-streams">
|
|
2434
|
+
<div class="o-ch"><b>Streams</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-stream-add">+ Stream</button>}</div>
|
|
2435
|
+
@if (addingDb()) {
|
|
2436
|
+
<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>
|
|
2437
|
+
}
|
|
2438
|
+
@for (st of d.streams; track st.name) {
|
|
2439
|
+
<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>
|
|
2440
|
+
@if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="removeStream(d, st.name)" [attr.aria-label]="'Remove ' + st.name" [disabled]="busy()">×</button>}</div>
|
|
2441
|
+
} @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
|
|
2442
|
+
<div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
|
|
2443
|
+
</div>
|
|
2444
|
+
} @else if (d.engine === 'rabbitmq') {
|
|
2445
|
+
<div class="o-card" data-testid="svc-vhosts">
|
|
2446
|
+
<div class="o-ch"><b>Virtual hosts</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-vhost-add">+ Virtual host</button>}</div>
|
|
2447
|
+
@if (addingDb()) {
|
|
2448
|
+
<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>
|
|
2449
|
+
}
|
|
2450
|
+
<div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
|
|
2451
|
+
@for (v of d.vhosts ?? []; track v) {
|
|
2452
|
+
<div class="o-row"><span class="o-mono o-b5">{{ v }}</span><span class="o-r o-faint o-sm">quorum queues</span>
|
|
2453
|
+
@if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="removeVhost(d, v)" [attr.aria-label]="'Remove ' + v" [disabled]="busy()">×</button>}</div>
|
|
2454
|
+
}
|
|
2455
|
+
<div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
|
|
2456
|
+
</div>
|
|
2457
|
+
} @else {
|
|
1849
2458
|
<div class="o-card">
|
|
1850
2459
|
<div class="o-ch"><b>Databases</b>@if (d.canManage && engine()?.databases) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-db-add">+ Database</button>}</div>
|
|
1851
2460
|
@if (addingDb()) {
|
|
@@ -1856,6 +2465,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1856
2465
|
@if (d.canManage && !first) {<button type="button" class="o-lnk o-faint" (click)="removeDb(d, db.name)" [attr.aria-label]="'Remove ' + db.name" [disabled]="busy()">×</button>}</div>
|
|
1857
2466
|
} @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
|
|
1858
2467
|
</div>
|
|
2468
|
+
}
|
|
1859
2469
|
<div class="o-card">
|
|
1860
2470
|
<div class="o-ch"><b>Users</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingUser.set(!addingUser())" data-testid="svc-user-add">+ User</button>}</div>
|
|
1861
2471
|
@if (addingUser()) {
|
|
@@ -1870,7 +2480,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1870
2480
|
<span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
|
|
1871
2481
|
@if (d.canManage && 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>
|
|
1872
2482
|
}
|
|
1873
|
-
<div class="o-cf">Per-app users
|
|
2483
|
+
<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</div>
|
|
1874
2484
|
</div>
|
|
1875
2485
|
</div>
|
|
1876
2486
|
@if (d.engine === 'postgres') {
|
|
@@ -1900,7 +2510,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1900
2510
|
<button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!d.canManage || !pitr() || !d.window?.from" data-testid="svc-pitr-go">Restore…</button>
|
|
1901
2511
|
</div>
|
|
1902
2512
|
} @else {
|
|
1903
|
-
<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
|
|
2513
|
+
<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></div>
|
|
1904
2514
|
}
|
|
1905
2515
|
<div class="o-card o-cb o-form">
|
|
1906
2516
|
<b>Schedule</b>
|
|
@@ -1913,7 +2523,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1913
2523
|
<div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.canManage" [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>
|
|
1914
2524
|
</div>
|
|
1915
2525
|
</div>
|
|
1916
|
-
<
|
|
2526
|
+
<o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
|
|
1917
2527
|
</div>
|
|
1918
2528
|
</div>
|
|
1919
2529
|
}
|
|
@@ -1924,10 +2534,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1924
2534
|
<o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
|
|
1925
2535
|
}
|
|
1926
2536
|
@case ('console') {
|
|
1927
|
-
<
|
|
1928
|
-
@if (d.databases.length) {<select class="o-in o-sm" style="width:auto" aria-label="Database" disabled>@for (db of d.databases; track db.name) {<option>{{ db.name }}</option>}</select>}</div>
|
|
1929
|
-
<div class="o-card"><textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" disabled aria-label="Query">{{ d.engine === 'valkey' ? 'INFO keyspace' : 'SELECT * FROM … LIMIT 50' }}</textarea><div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" disabled>Run</button></div></div>
|
|
1930
|
-
<span class="o-faint o-sm">The console arrives at GA. Until then, connect with the snippets on Overview from an attached app, or reveal the password.</span>
|
|
2537
|
+
<o-svc-console [d]="d" [projectKey]="key()" [kind]="engine()?.console ?? 'sql'" />
|
|
1931
2538
|
}
|
|
1932
2539
|
@case ('settings') {
|
|
1933
2540
|
<div class="o-col" style="gap:14px;max-width:820px">
|
|
@@ -1942,7 +2549,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1942
2549
|
</div>
|
|
1943
2550
|
<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>
|
|
1944
2551
|
</div>
|
|
1945
|
-
<div class="o-card"
|
|
2552
|
+
<div class="o-card" data-testid="svc-ha">
|
|
2553
|
+
<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>
|
|
2554
|
+
@switch (engine()?.ha) {
|
|
2555
|
+
@case ('standby') {
|
|
2556
|
+
<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.canManage || busy()" (click)="topology(d, !d.standby, d.standby ? 0 : d.replicas)" aria-label="Standby" data-testid="svc-standby"></button></div>
|
|
2557
|
+
<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>
|
|
2558
|
+
@if (d.canManage) {
|
|
2559
|
+
<button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
|
|
2560
|
+
<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>
|
|
2561
|
+
}</div>
|
|
2562
|
+
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2563
|
+
}
|
|
2564
|
+
@case ('cluster') {
|
|
2565
|
+
<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.canManage || busy()" (click)="topology(d, !d.standby, 0)" aria-label="3-node cluster" data-testid="svc-standby"></button></div>
|
|
2566
|
+
<div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
|
|
2567
|
+
}
|
|
2568
|
+
@default {
|
|
2569
|
+
<div class="o-row"><span style="flex:1">Replica with failover</span><b class="o-ph-tag o-later">Later</b></div>
|
|
2570
|
+
<div class="o-cf">{{ d.engineName }} runs one node for now; daily snapshots keep its data.</div>
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
</div>
|
|
1946
2574
|
<div class="o-card">
|
|
1947
2575
|
<div class="o-ch"><b>Networking</b></div>
|
|
1948
2576
|
<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>
|
|
@@ -1963,9 +2591,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
1963
2591
|
<span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
|
|
1964
2592
|
</div>
|
|
1965
2593
|
</div>
|
|
1966
|
-
<div class="o-card"><div class="o-ch"><b>Engine parameters</b><
|
|
1967
|
-
@for (p of
|
|
1968
|
-
|
|
2594
|
+
<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">
|
|
2595
|
+
@for (p of d.parameters; track p.name) {<div class="o-tr">
|
|
2596
|
+
<div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
|
|
2597
|
+
<div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [placeholder]="p.default" [disabled]="!d.canManage" [attr.aria-label]="p.name" [ngModel]="paramDraft()[p.name] ?? p.value ?? ''" (ngModelChange)="setParam(p, $event)" [attr.data-testid]="'svc-param-' + p.name" /></div>
|
|
2598
|
+
<div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
|
|
2599
|
+
<div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
|
|
2600
|
+
</div>}
|
|
2601
|
+
</div></div>
|
|
2602
|
+
@if (d.canManage) {<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>}
|
|
2603
|
+
</div>
|
|
1969
2604
|
@if (d.canManage && !d.deletedAt) {
|
|
1970
2605
|
<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>
|
|
1971
2606
|
}
|
|
@@ -2024,5 +2659,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
2024
2659
|
* Generated bundle index. Do not edit.
|
|
2025
2660
|
*/
|
|
2026
2661
|
|
|
2027
|
-
export { DAYS, ENGINE_COLOUR, LIFECYCLE, PRESETS, ServiceLogsTab, ServiceMetricsTab, ServicePage, ServicesApi, ServicesHost, ServicesListPage, ServicesNewPage, apiMessage, backupKind, backupsLine, bytes, fromLocalInput, hhmm, hhmmss, money, moving, num, pct, presetChip, ratio, shortDate, spark, sparkPath, stateChip, stateDot, toLocalInput, utcHour, when };
|
|
2662
|
+
export { DAYS, ENGINE_COLOUR, LIFECYCLE, PRESETS, ServiceConsoleTab, ServiceDumpsCard, ServiceLogsTab, ServiceMetricsTab, ServicePage, ServicesApi, ServicesHost, ServicesListPage, ServicesNewPage, apiMessage, backupKind, backupsLine, bytes, fromLocalInput, hhmm, hhmmss, money, moving, num, pct, presetChip, ratio, shortDate, spark, sparkPath, stateChip, stateDot, toLocalInput, utcHour, when };
|
|
2028
2663
|
//# sourceMappingURL=chisquare-tech-origine-ngx-apps-services.mjs.map
|