@chisquare-tech/origine-ngx-apps-services 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
- <div class="o-card o-cb o-flex"><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><b class="o-ph-tag o-ga o-r">GA</b></div>
358
- <div class="o-card o-cb o-flex"><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 · management UI through Origine</span></div><b class="o-ph-tag o-ga o-r">GA</b></div>
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
- <div class="o-card o-cb o-flex"><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><b class="o-ph-tag o-ga o-r">GA</b></div>
408
- <div class="o-card o-cb o-flex"><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 · management UI through Origine</span></div><b class="o-ph-tag o-ga o-r">GA</b></div>
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 first = catalog.engines.find((e) => e.available) ?? null;
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 <b class="o-ph-tag o-ga">GA</b></label><div class="o-flex"><button type="button" class="o-tog" disabled aria-label="High availability (GA)"></button><span class="o-sm">Standby node with automatic failover</span></div></div>
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) {
@@ -636,9 +695,13 @@ class ServicesNewPage {
636
695
  <span class="o-hint">Injects {{ engine()?.envVar }} into the app and redeploys it.</span>
637
696
  </div>
638
697
  <div class="o-g2">
639
- <div class="o-fld"><label for="svc-hour">Daily backup at</label>
640
- <select id="svc-hour" class="o-in" [ngModel]="hour()" (ngModelChange)="hour.set(+$event)">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select>
641
- </div>
698
+ @if (engine()?.id === 'mysql') {
699
+ <div class="o-fld"><label>Backups</label><input class="o-in" value="Every hour" readonly aria-label="Backups" /></div>
700
+ } @else {
701
+ <div class="o-fld"><label for="svc-hour">Daily backup at</label>
702
+ <select id="svc-hour" class="o-in" [ngModel]="hour()" (ngModelChange)="hour.set(+$event)">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select>
703
+ </div>
704
+ }
642
705
  <div class="o-fld"><label for="svc-keep">Retention</label>
643
706
  <select id="svc-keep" class="o-in" [ngModel]="keep()" (ngModelChange)="keep.set(+$event)">@for (d of keeps; track d) {<option [ngValue]="d">{{ d }} days</option>}</select>
644
707
  </div>
@@ -658,9 +721,10 @@ class ServicesNewPage {
658
721
  <span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
659
722
  <span class="o-k">Region</span><span>{{ region() }}</span>
660
723
  <span class="o-k">Network</span><span>Private · TLS</span>
661
- <span class="o-k">Backups</span><span>Daily{{ engine()?.pitr ? ' + PITR' : ' RDB snapshot' }}</span>
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>
662
726
  </div>
663
- <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>
664
728
  <span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
665
729
  </div>
666
730
  </div>
@@ -721,7 +785,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
721
785
  @if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
722
786
  <div class="o-g2">
723
787
  <div class="o-fld"><label>Region</label><input class="o-in" [value]="region()" readonly aria-label="Region" /></div>
724
- <div class="o-fld"><label>High availability <b class="o-ph-tag o-ga">GA</b></label><div class="o-flex"><button type="button" class="o-tog" disabled aria-label="High availability (GA)"></button><span class="o-sm">Standby node with automatic failover</span></div></div>
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>
725
793
  </div>
726
794
  }
727
795
  @if (step() === 3) {
@@ -751,9 +819,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
751
819
  <span class="o-hint">Injects {{ engine()?.envVar }} into the app and redeploys it.</span>
752
820
  </div>
753
821
  <div class="o-g2">
754
- <div class="o-fld"><label for="svc-hour">Daily backup at</label>
755
- <select id="svc-hour" class="o-in" [ngModel]="hour()" (ngModelChange)="hour.set(+$event)">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select>
756
- </div>
822
+ @if (engine()?.id === 'mysql') {
823
+ <div class="o-fld"><label>Backups</label><input class="o-in" value="Every hour" readonly aria-label="Backups" /></div>
824
+ } @else {
825
+ <div class="o-fld"><label for="svc-hour">Daily backup at</label>
826
+ <select id="svc-hour" class="o-in" [ngModel]="hour()" (ngModelChange)="hour.set(+$event)">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select>
827
+ </div>
828
+ }
757
829
  <div class="o-fld"><label for="svc-keep">Retention</label>
758
830
  <select id="svc-keep" class="o-in" [ngModel]="keep()" (ngModelChange)="keep.set(+$event)">@for (d of keeps; track d) {<option [ngValue]="d">{{ d }} days</option>}</select>
759
831
  </div>
@@ -773,9 +845,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
773
845
  <span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
774
846
  <span class="o-k">Region</span><span>{{ region() }}</span>
775
847
  <span class="o-k">Network</span><span>Private · TLS</span>
776
- <span class="o-k">Backups</span><span>Daily{{ engine()?.pitr ? ' + PITR' : ' RDB snapshot' }}</span>
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>
777
850
  </div>
778
- <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>
779
852
  <span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
780
853
  </div>
781
854
  </div>
@@ -784,6 +857,396 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
784
857
  }]
785
858
  }], ctorParameters: () => [] });
786
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()" (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()" (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" }, 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
+ template: `
1204
+ <div class="o-card o-cb o-col" style="gap:8px" data-testid="svc-fork-card">
1205
+ <b>Fork for staging</b>
1206
+ <span class="o-hint">Copy from the latest backup into a new instance. {{ d().name }} keeps running untouched.</span>
1207
+ @if (d().canManage) {
1208
+ <div class="o-flex o-wrap">
1209
+ <select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-fork-env">
1210
+ @for (e of envs; track e) {<option [value]="e">{{ e }}</option>}
1211
+ </select>
1212
+ <button type="button" class="o-btn o-sm o-r" (click)="fork()" [disabled]="busy() || !hasBackup()" data-testid="svc-fork">Fork</button>
1213
+ </div>
1214
+ @if (!hasBackup()) {<span class="o-faint o-xs">Available after the first completed backup.</span>}
1215
+ }
1216
+ </div>
1217
+ @if (dumps()) {
1218
+ <div class="o-card" data-testid="svc-dumps">
1219
+ <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>
1220
+ @for (x of d().dumps; track x.id) {
1221
+ <div class="o-row" [attr.data-testid]="'svc-dump-' + x.id">
1222
+ <span [class]="'o-dot ' + dot(x)"></span>
1223
+ <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>
1224
+ <span class="o-faint o-xs">{{ x.phase === 'completed' ? (x.bytes ? size(x.bytes) + ' · ' : '') + when(x.stoppedAt || x.createdAt) : x.phase }}</span>
1225
+ @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>}
1226
+ @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>}
1227
+ </div>
1228
+ @if (x.error) {<div class="o-row o-sm" style="color:var(--o-bad-tx)">{{ x.error }}</div>}
1229
+ } @empty {<div class="o-empty">No dumps yet. Export one to download it, or import a dump into {{ d().name }}.</div>}
1230
+ @if (d().canManage) {
1231
+ <div class="o-cb o-col" style="gap:8px;border-top:1px solid var(--o-line-2)">
1232
+ <b class="o-sm">Import</b>
1233
+ <div class="o-flex o-wrap">
1234
+ <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>
1235
+ <span class="o-faint o-xs">{{ d().engine === 'mysql' ? 'SQL' : 'pg_dump custom format or SQL' }}, gzipped or not, up to 5 GB</span>
1236
+ </div>
1237
+ <div class="o-flex o-wrap">
1238
+ <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" />
1239
+ <button type="button" class="o-btn o-sm" (click)="importRemote()" [disabled]="busy() || !remote().trim() || d().state !== 'Running'" data-testid="svc-import-go">Import</button>
1240
+ </div>
1241
+ <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>
1242
+ </div>
1243
+ }
1244
+ </div>
1245
+ }
1246
+ `,
1247
+ }]
1248
+ }], 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"] }] } });
1249
+
787
1250
  const COLOUR = { ERROR: 'var(--o-bad-solid)', FATAL: 'var(--o-bad-solid)', PANIC: 'var(--o-bad-solid)', WARNING: 'var(--o-run)' };
788
1251
  // The instance page's Logs tab (prototype "svc · logs"): the engine's own log from its pods —
789
1252
  // all, errors, the slow log, connections — newest first, refreshed every 10 s.
@@ -1066,16 +1529,11 @@ const TABS = [
1066
1529
  { id: 'overview', label: 'Overview' }, { id: 'users', label: 'Databases and users' }, { id: 'backups', label: 'Backups' }, { id: 'metrics', label: 'Metrics' },
1067
1530
  { id: 'logs', label: 'Logs' }, { id: 'console', label: 'Console' }, { id: 'settings', label: 'Settings' },
1068
1531
  ];
1069
- /** Engine parameters the prototype lists (validated subset, GA). */
1070
- const PARAMS = {
1071
- postgres: [['max_connections', '100', 'restart'], ['work_mem', '4MB', 'live'], ['log_min_duration_statement', '250ms', 'live']],
1072
- mysql: [['max_connections', '151', 'restart'], ['long_query_time', '0.25', 'live'], ['innodb_buffer_pool_size', 'auto', 'restart']],
1073
- valkey: [['maxmemory-policy', 'allkeys-lru', 'live'], ['timeout', '0', 'live'], ['slowlog-log-slower-than', '10000', 'live']],
1074
- };
1075
1532
  // /{org}/{project}/services/:id (prototype "services"): one instance — its connection details and
1076
1533
  // reveal, attached apps, databases and users, backups and point-in-time restore, metrics and engine
1077
- // logs read from the cell, and settings (resize, networking, maintenance, delete). Console, HA and
1078
- // engine parameters are tagged GA, as the prototype tags them.
1534
+ // logs read from the cell, the console (SQL, or the key, stream and queue inspector), and settings
1535
+ // (resize, high availability, networking, maintenance, engine parameters, delete). Services v1
1536
+ // (P5-41): NATS streams and RabbitMQ vhosts, per-app users, fork and dumps.
1079
1537
  class ServicePage {
1080
1538
  constructor() {
1081
1539
  this.host = inject(ServicesHost);
@@ -1111,6 +1569,13 @@ class ServicePage {
1111
1569
  this.attApp = signal('', ...(ngDevMode ? [{ debugName: "attApp" }] : /* istanbul ignore next */ []));
1112
1570
  this.attEnv = signal('production', ...(ngDevMode ? [{ debugName: "attEnv" }] : /* istanbul ignore next */ []));
1113
1571
  this.attPrefix = signal('', ...(ngDevMode ? [{ debugName: "attPrefix" }] : /* istanbul ignore next */ []));
1572
+ this.attOwn = signal(false, ...(ngDevMode ? [{ debugName: "attOwn" }] : /* istanbul ignore next */ []));
1573
+ this.attVhost = signal('/', ...(ngDevMode ? [{ debugName: "attVhost" }] : /* istanbul ignore next */ []));
1574
+ this.subjects = signal('', ...(ngDevMode ? [{ debugName: "subjects" }] : /* istanbul ignore next */ []));
1575
+ this.maxAge = signal(168, ...(ngDevMode ? [{ debugName: "maxAge" }] : /* istanbul ignore next */ []));
1576
+ 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' }];
1577
+ this.paramDraft = signal({}, ...(ngDevMode ? [{ debugName: "paramDraft" }] : /* istanbul ignore next */ []));
1578
+ this.paramsChanged = computed(() => Object.keys(this.paramDraft()).length > 0, ...(ngDevMode ? [{ debugName: "paramsChanged" }] : /* istanbul ignore next */ []));
1114
1579
  this.addingDb = signal(false, ...(ngDevMode ? [{ debugName: "addingDb" }] : /* istanbul ignore next */ []));
1115
1580
  this.dbName = signal('', ...(ngDevMode ? [{ debugName: "dbName" }] : /* istanbul ignore next */ []));
1116
1581
  this.addingUser = signal(false, ...(ngDevMode ? [{ debugName: "addingUser" }] : /* istanbul ignore next */ []));
@@ -1126,11 +1591,10 @@ class ServicePage {
1126
1591
  this.envVar = computed(() => this.engine()?.envVar ?? 'DATABASE_URL', ...(ngDevMode ? [{ debugName: "envVar" }] : /* istanbul ignore next */ []));
1127
1592
  this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
1128
1593
  this.region = computed(() => this.host.project()?.region || 'your region', ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
1129
- this.params = computed(() => PARAMS[this.d()?.engine ?? ''] ?? [], ...(ngDevMode ? [{ debugName: "params" }] : /* istanbul ignore next */ []));
1130
1594
  this.conn = computed(() => this.revealed()?.connection ?? this.d()?.connection ?? null, ...(ngDevMode ? [{ debugName: "conn" }] : /* istanbul ignore next */ []));
1131
1595
  this.snippetKeys = computed(() => {
1132
1596
  const s = this.conn()?.snippets ?? {};
1133
- return ['uri', 'psql', 'mysql', 'valkey-cli', 'node'].filter((k) => k in s);
1597
+ return ['uri', 'psql', 'mysql', 'valkey-cli', 'nats', 'node', 'cli'].filter((k) => k in s);
1134
1598
  }, ...(ngDevMode ? [{ debugName: "snippetKeys" }] : /* istanbul ignore next */ []));
1135
1599
  this.backups = computed(() => [...(this.d()?.backups ?? [])].sort((a, b) => (b.startedAt ?? '9').localeCompare(a.startedAt ?? '9')), ...(ngDevMode ? [{ debugName: "backups" }] : /* istanbul ignore next */ []));
1136
1600
  this.attEnvs = computed(() => {
@@ -1189,6 +1653,11 @@ class ServicePage {
1189
1653
  const q = fig('queries');
1190
1654
  out.push({ label: 'Queries / s', value: q?.rate != null ? num(q.rate, 1) : '–', sub: `slow ${num(fig('slow_queries')?.value)} since start` });
1191
1655
  }
1656
+ else if (d?.engine === 'nats' || d?.engine === 'rabbitmq') {
1657
+ const pub = fig('published');
1658
+ const depth = fig(d.engine === 'nats' ? 'stream_depth' : 'queue_depth');
1659
+ 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` });
1660
+ }
1192
1661
  else {
1193
1662
  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)}` });
1194
1663
  }
@@ -1286,7 +1755,7 @@ class ServicePage {
1286
1755
  return `${cpu} / ${ram} / ${d.diskGb} GB`;
1287
1756
  }
1288
1757
  snippetLabel(k) {
1289
- return { uri: 'URI', node: 'Node' }[k] ?? k;
1758
+ return { uri: 'URI', node: 'Node', cli: 'origine CLI', nats: 'nats' }[k] ?? k;
1290
1759
  }
1291
1760
  attState(a) {
1292
1761
  return a.state === 'live' ? 'Injected' : a.state === 'failed' ? 'Failed' : 'Redeploying…';
@@ -1309,7 +1778,8 @@ class ServicePage {
1309
1778
  const w = d.window;
1310
1779
  if (!w?.from)
1311
1780
  return 'Available after the first backup.';
1312
- return `Any second since ${when(w.from)}${w.to ? ' until ' + when(w.to) : ''}.`;
1781
+ const span = `Any second since ${when(w.from)}${w.to ? ' until ' + when(w.to) : ''}.`;
1782
+ return d.engine === 'mysql' ? `${span} MySQL's window ends at the latest hourly backup.` : span;
1313
1783
  }
1314
1784
  keepLine(b, days) {
1315
1785
  if (b.kind === 'on-demand')
@@ -1355,9 +1825,10 @@ class ServicePage {
1355
1825
  }
1356
1826
  attach(d) {
1357
1827
  const app = this.attApp().trim();
1358
- this.run(this.api.attach(this.key(), d.id, { app, environment: this.attEnv(), prefix: this.attPrefix().trim() }), () => {
1828
+ const body = { app, environment: this.attEnv(), prefix: this.attPrefix().trim(), ownUser: this.attOwn(), database: d.engine === 'rabbitmq' ? this.attVhost() : undefined };
1829
+ this.run(this.api.attach(this.key(), d.id, body), (a) => {
1359
1830
  this.attachOpen.set(false);
1360
- this.notice.set(`${d.name} attached to ${app} · ${this.attEnv()}: ${this.attPrefix()}${this.envVar()} is injected and ${app} redeploys.`);
1831
+ 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.`);
1361
1832
  }, 'The service could not be attached.');
1362
1833
  }
1363
1834
  detach(d, a) {
@@ -1467,6 +1938,70 @@ class ServicePage {
1467
1938
  this.notice.set(`${d.name} is scheduled for deletion · undo within 24 hours.`);
1468
1939
  }, 'The service could not be deleted.');
1469
1940
  }
1941
+ // ---- v1 -------------------------------------------------------------------
1942
+ nodesPrice(d) {
1943
+ return money((this.plans().find((p) => p.id === d.plan)?.priceCents ?? 0) * d.nodes);
1944
+ }
1945
+ refresh() {
1946
+ this.load();
1947
+ }
1948
+ snapshotName(d) {
1949
+ return d.engine === 'nats' ? 'stream snapshot' : d.engine === 'rabbitmq' ? 'definitions export' : 'RDB snapshot';
1950
+ }
1951
+ /** MySQL replicas come in pairs. */
1952
+ step(d) {
1953
+ return d.engine === 'mysql' ? 2 : 1;
1954
+ }
1955
+ topology(d, standby, replicas) {
1956
+ const nodes = d.engine === 'nats' || d.engine === 'rabbitmq' ? (standby ? 3 : 1) : 1 + replicas + (standby ? (d.engine === 'mysql' ? 2 : 1) : 0);
1957
+ this.save(d, { standby, replicas: Math.max(0, replicas) }, `${d.name} now runs ${nodes} node${nodes === 1 ? '' : 's'}${standby ? ' with automatic failover' : ''}.`);
1958
+ }
1959
+ setParam(p, v) {
1960
+ this.paramDraft.update((m) => ({ ...m, [p.name]: v }));
1961
+ }
1962
+ paramNote(d) {
1963
+ const changed = Object.keys(this.paramDraft());
1964
+ const restarts = d.parameters.filter((p) => p.restart && changed.includes(p.name)).map((p) => p.name);
1965
+ if (!changed.length)
1966
+ return 'Empty fields keep the default (shown greyed).';
1967
+ return restarts.length ? `${restarts.join(', ')} restart${restarts.length === 1 ? 's' : ''} ${d.engineName}${d.nodes > 1 ? ' one node at a time' : ''}.` : 'Applied live.';
1968
+ }
1969
+ saveParams(d) {
1970
+ const all = {};
1971
+ for (const p of d.parameters) {
1972
+ const v = this.paramDraft()[p.name] ?? p.value ?? '';
1973
+ if (v.trim())
1974
+ all[p.name] = v.trim();
1975
+ }
1976
+ this.run(this.api.update(this.key(), d.id, { parameters: all }), () => {
1977
+ this.paramDraft.set({});
1978
+ this.notice.set('Parameters saved; the cell applies them now.');
1979
+ }, 'The parameters could not be saved.');
1980
+ }
1981
+ addStream(d) {
1982
+ const name = this.dbName().trim();
1983
+ const subjects = this.subjects().split(',').map((x) => x.trim()).filter(Boolean);
1984
+ this.run(this.api.addStream(this.key(), d.id, { name, subjects, maxAgeHours: this.maxAge() }), () => {
1985
+ this.addingDb.set(false);
1986
+ this.dbName.set('');
1987
+ this.subjects.set('');
1988
+ this.notice.set(`Stream ${name} created.`);
1989
+ }, 'The stream could not be created.');
1990
+ }
1991
+ removeStream(d, name) {
1992
+ 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.');
1993
+ }
1994
+ addVhost(d) {
1995
+ const name = this.dbName().trim();
1996
+ this.run(this.api.addVhost(this.key(), d.id, name), () => {
1997
+ this.addingDb.set(false);
1998
+ this.dbName.set('');
1999
+ this.notice.set(`Virtual host ${name} created.`);
2000
+ }, 'The virtual host could not be created.');
2001
+ }
2002
+ removeVhost(d, name) {
2003
+ this.run(this.api.removeVhost(this.key(), d.id, name), () => this.notice.set(`Virtual host ${name} removed.`), 'The virtual host could not be removed.');
2004
+ }
1470
2005
  undelete(d) {
1471
2006
  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.');
1472
2007
  }
@@ -1538,7 +2073,7 @@ class ServicePage {
1538
2073
  <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>
1539
2074
  @for (a of d.attachments; track a.id) {
1540
2075
  <div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
1541
- <span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
2076
+ <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>}
1542
2077
  <span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
1543
2078
  @if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
1544
2079
  </div>
@@ -1550,6 +2085,8 @@ class ServicePage {
1550
2085
  } @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" />}
1551
2086
  <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>
1552
2087
  <input class="o-in o-sm o-mono" style="width:110px" placeholder="prefix (opt.)" aria-label="Prefix" [ngModel]="attPrefix()" (ngModelChange)="attPrefix.set($event)" />
2088
+ @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>}
2089
+ <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>
1553
2090
  <span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
1554
2091
  <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>
1555
2092
  </div>
@@ -1557,7 +2094,7 @@ class ServicePage {
1557
2094
  <div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.canManage) { · <button type="button" class="o-lnk" (click)="retire(d, r)" [disabled]="busy()">Retire the old password now</button>}}</div>
1558
2095
  </div>
1559
2096
  <div class="o-card">
1560
- <div class="o-ch"><b>Backups</b><span class="o-r">Daily · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
2097
+ <div class="o-ch"><b>Backups</b><span class="o-r">{{ d.engine === 'mysql' ? 'Hourly' : 'Daily' }} · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
1561
2098
  @for (b of backups().slice(0, 3); track b.name) {
1562
2099
  <div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && d.canManage) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore to new instance</button>}</div>
1563
2100
  } @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
@@ -1568,6 +2105,32 @@ class ServicePage {
1568
2105
  }
1569
2106
  @case ('users') {
1570
2107
  <div class="o-g2" style="align-items:start;gap:18px">
2108
+ @if (d.engine === 'nats') {
2109
+ <div class="o-card" data-testid="svc-streams">
2110
+ <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>
2111
+ @if (addingDb()) {
2112
+ <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>
2113
+ }
2114
+ @for (st of d.streams; track st.name) {
2115
+ <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>
2116
+ @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>
2117
+ } @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
2118
+ <div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
2119
+ </div>
2120
+ } @else if (d.engine === 'rabbitmq') {
2121
+ <div class="o-card" data-testid="svc-vhosts">
2122
+ <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>
2123
+ @if (addingDb()) {
2124
+ <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>
2125
+ }
2126
+ <div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
2127
+ @for (v of d.vhosts ?? []; track v) {
2128
+ <div class="o-row"><span class="o-mono o-b5">{{ v }}</span><span class="o-r o-faint o-sm">quorum queues</span>
2129
+ @if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="removeVhost(d, v)" [attr.aria-label]="'Remove ' + v" [disabled]="busy()">×</button>}</div>
2130
+ }
2131
+ <div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
2132
+ </div>
2133
+ } @else {
1571
2134
  <div class="o-card">
1572
2135
  <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>
1573
2136
  @if (addingDb()) {
@@ -1578,6 +2141,7 @@ class ServicePage {
1578
2141
  @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>
1579
2142
  } @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
1580
2143
  </div>
2144
+ }
1581
2145
  <div class="o-card">
1582
2146
  <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>
1583
2147
  @if (addingUser()) {
@@ -1592,7 +2156,7 @@ class ServicePage {
1592
2156
  <span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
1593
2157
  @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>
1594
2158
  }
1595
- <div class="o-cf">Per-app users created on attach, each with least privilege<b class="o-ph-tag o-ga">GA</b></div>
2159
+ <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>
1596
2160
  </div>
1597
2161
  </div>
1598
2162
  @if (d.engine === 'postgres') {
@@ -1622,16 +2186,20 @@ class ServicePage {
1622
2186
  <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>
1623
2187
  </div>
1624
2188
  } @else {
1625
- <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 RDB snapshot: pick one on the left. It always creates a new instance.</span></div>
2189
+ <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>
1626
2190
  }
1627
2191
  <div class="o-card o-cb o-form">
1628
2192
  <b>Schedule</b>
1629
2193
  <div class="o-g2">
1630
- <div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.canManage" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
2194
+ @if (d.engine === 'mysql') {
2195
+ <div class="o-fld"><label>Runs</label><input class="o-in" value="Every hour, on the hour" readonly aria-label="Runs" /><span class="o-hint">MySQL restores to any second up to the latest backup: at most an hour behind.</span></div>
2196
+ } @else {
2197
+ <div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.canManage" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
2198
+ }
1631
2199
  <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>
1632
2200
  </div>
1633
2201
  </div>
1634
- <div class="o-card o-cb o-col" style="gap:8px"><div class="o-flex"><b>Fork for staging</b><b class="o-ph-tag o-ga">GA</b></div><span class="o-hint">Copy from the latest backup into a new instance.</span><div class="o-flex"><b>Export dump</b><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-xs o-r" disabled>{{ d.engine === 'mysql' ? 'mysqldump' : d.engine === 'valkey' ? 'RDB' : 'pg_dump' }}</button></div></div>
2202
+ <o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
1635
2203
  </div>
1636
2204
  </div>
1637
2205
  }
@@ -1642,10 +2210,7 @@ class ServicePage {
1642
2210
  <o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
1643
2211
  }
1644
2212
  @case ('console') {
1645
- <div class="o-flex o-wrap"><span class="o-chip o-grn">Read-only</span><span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as console_ro · audited</span><b class="o-ph-tag o-ga">GA</b><span class="o-r"></span>
1646
- @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>
1647
- <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>
1648
- <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>
2213
+ <o-svc-console [d]="d" [projectKey]="key()" [kind]="engine()?.console ?? 'sql'" />
1649
2214
  }
1650
2215
  @case ('settings') {
1651
2216
  <div class="o-col" style="gap:14px;max-width:820px">
@@ -1660,7 +2225,28 @@ class ServicePage {
1660
2225
  </div>
1661
2226
  <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>
1662
2227
  </div>
1663
- <div class="o-card"><div class="o-ch"><b>High availability</b><b class="o-ph-tag o-ga">GA</b></div><div class="o-row"><span style="flex:1">Standby node with automatic failover</span><button type="button" class="o-tog" disabled aria-label="Standby (GA)"></button></div><div class="o-row"><span style="flex:1">Read replicas</span><span class="o-faint o-sm">0</span><button type="button" class="o-btn o-xs" disabled>+ Replica</button></div></div>
2228
+ <div class="o-card" data-testid="svc-ha">
2229
+ <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>
2230
+ @switch (engine()?.ha) {
2231
+ @case ('standby') {
2232
+ <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>
2233
+ <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>
2234
+ @if (d.canManage) {
2235
+ <button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
2236
+ <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>
2237
+ }</div>
2238
+ <div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
2239
+ }
2240
+ @case ('cluster') {
2241
+ <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>
2242
+ <div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
2243
+ }
2244
+ @default {
2245
+ <div class="o-row"><span style="flex:1">Replica with failover</span><b class="o-ph-tag o-later">Later</b></div>
2246
+ <div class="o-cf">{{ d.engineName }} runs one node for now; daily snapshots keep its data.</div>
2247
+ }
2248
+ }
2249
+ </div>
1664
2250
  <div class="o-card">
1665
2251
  <div class="o-ch"><b>Networking</b></div>
1666
2252
  <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>
@@ -1681,9 +2267,16 @@ class ServicePage {
1681
2267
  <span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
1682
2268
  </div>
1683
2269
  </div>
1684
- <div class="o-card"><div class="o-ch"><b>Engine parameters</b><b class="o-ph-tag o-ga">GA</b><span class="o-r">validated subset</span></div><div class="o-tbl"><div class="o-tbody">
1685
- @for (p of params(); track p[0]) {<div class="o-tr"><div class="o-td o-mono o-sm">{{ p[0] }}</div><div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [value]="p[1]" disabled [attr.aria-label]="p[0]" /></div><div class="o-td"><span class="o-chip" [class.o-amb]="p[2] === 'restart'">{{ p[2] }}</span></div></div>}
1686
- </div></div></div>
2270
+ <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">
2271
+ @for (p of d.parameters; track p.name) {<div class="o-tr">
2272
+ <div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
2273
+ <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>
2274
+ <div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
2275
+ <div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
2276
+ </div>}
2277
+ </div></div>
2278
+ @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>}
2279
+ </div>
1687
2280
  @if (d.canManage && !d.deletedAt) {
1688
2281
  <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>
1689
2282
  }
@@ -1728,13 +2321,13 @@ class ServicePage {
1728
2321
  } @else {
1729
2322
  <div class="o-page">@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>} @else {<div class="o-empty">Loading…</div>}</div>
1730
2323
  }
1731
- `, 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 }); }
2324
+ `, 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 }); }
1732
2325
  }
1733
2326
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicePage, decorators: [{
1734
2327
  type: Component,
1735
2328
  args: [{
1736
2329
  selector: 'o-service',
1737
- imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab],
2330
+ imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab, ServiceConsoleTab, ServiceDumpsCard],
1738
2331
  changeDetection: ChangeDetectionStrategy.OnPush,
1739
2332
  template: `
1740
2333
  @if (d(); as d) {
@@ -1803,7 +2396,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1803
2396
  <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>
1804
2397
  @for (a of d.attachments; track a.id) {
1805
2398
  <div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
1806
- <span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
2399
+ <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>}
1807
2400
  <span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
1808
2401
  @if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
1809
2402
  </div>
@@ -1815,6 +2408,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1815
2408
  } @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" />}
1816
2409
  <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>
1817
2410
  <input class="o-in o-sm o-mono" style="width:110px" placeholder="prefix (opt.)" aria-label="Prefix" [ngModel]="attPrefix()" (ngModelChange)="attPrefix.set($event)" />
2411
+ @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>}
2412
+ <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>
1818
2413
  <span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
1819
2414
  <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>
1820
2415
  </div>
@@ -1822,7 +2417,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1822
2417
  <div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.canManage) { · <button type="button" class="o-lnk" (click)="retire(d, r)" [disabled]="busy()">Retire the old password now</button>}}</div>
1823
2418
  </div>
1824
2419
  <div class="o-card">
1825
- <div class="o-ch"><b>Backups</b><span class="o-r">Daily · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
2420
+ <div class="o-ch"><b>Backups</b><span class="o-r">{{ d.engine === 'mysql' ? 'Hourly' : 'Daily' }} · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
1826
2421
  @for (b of backups().slice(0, 3); track b.name) {
1827
2422
  <div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && d.canManage) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore to new instance</button>}</div>
1828
2423
  } @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
@@ -1833,6 +2428,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1833
2428
  }
1834
2429
  @case ('users') {
1835
2430
  <div class="o-g2" style="align-items:start;gap:18px">
2431
+ @if (d.engine === 'nats') {
2432
+ <div class="o-card" data-testid="svc-streams">
2433
+ <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>
2434
+ @if (addingDb()) {
2435
+ <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>
2436
+ }
2437
+ @for (st of d.streams; track st.name) {
2438
+ <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>
2439
+ @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>
2440
+ } @empty {<div class="o-empty">No streams declared. Apps can make their own; the console lists every stream and KV bucket.</div>}
2441
+ <div class="o-cf">JetStream on the volume · {{ d.nodes > 1 ? 'replicated on 3 servers' : 'one server' }}</div>
2442
+ </div>
2443
+ } @else if (d.engine === 'rabbitmq') {
2444
+ <div class="o-card" data-testid="svc-vhosts">
2445
+ <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>
2446
+ @if (addingDb()) {
2447
+ <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>
2448
+ }
2449
+ <div class="o-row"><span class="o-mono o-b5">/</span><span class="o-faint o-sm">default</span></div>
2450
+ @for (v of d.vhosts ?? []; track v) {
2451
+ <div class="o-row"><span class="o-mono o-b5">{{ v }}</span><span class="o-r o-faint o-sm">quorum queues</span>
2452
+ @if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="removeVhost(d, v)" [attr.aria-label]="'Remove ' + v" [disabled]="busy()">×</button>}</div>
2453
+ }
2454
+ <div class="o-cf">Queues default to quorum queues · every user gets its preset on each virtual host</div>
2455
+ </div>
2456
+ } @else {
1836
2457
  <div class="o-card">
1837
2458
  <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>
1838
2459
  @if (addingDb()) {
@@ -1843,6 +2464,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1843
2464
  @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>
1844
2465
  } @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
1845
2466
  </div>
2467
+ }
1846
2468
  <div class="o-card">
1847
2469
  <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>
1848
2470
  @if (addingUser()) {
@@ -1857,7 +2479,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1857
2479
  <span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
1858
2480
  @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>
1859
2481
  }
1860
- <div class="o-cf">Per-app users created on attach, each with least privilege<b class="o-ph-tag o-ga">GA</b></div>
2482
+ <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>
1861
2483
  </div>
1862
2484
  </div>
1863
2485
  @if (d.engine === 'postgres') {
@@ -1887,16 +2509,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1887
2509
  <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>
1888
2510
  </div>
1889
2511
  } @else {
1890
- <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 RDB snapshot: pick one on the left. It always creates a new instance.</span></div>
2512
+ <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>
1891
2513
  }
1892
2514
  <div class="o-card o-cb o-form">
1893
2515
  <b>Schedule</b>
1894
2516
  <div class="o-g2">
1895
- <div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.canManage" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
2517
+ @if (d.engine === 'mysql') {
2518
+ <div class="o-fld"><label>Runs</label><input class="o-in" value="Every hour, on the hour" readonly aria-label="Runs" /><span class="o-hint">MySQL restores to any second up to the latest backup: at most an hour behind.</span></div>
2519
+ } @else {
2520
+ <div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.canManage" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
2521
+ }
1896
2522
  <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>
1897
2523
  </div>
1898
2524
  </div>
1899
- <div class="o-card o-cb o-col" style="gap:8px"><div class="o-flex"><b>Fork for staging</b><b class="o-ph-tag o-ga">GA</b></div><span class="o-hint">Copy from the latest backup into a new instance.</span><div class="o-flex"><b>Export dump</b><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-xs o-r" disabled>{{ d.engine === 'mysql' ? 'mysqldump' : d.engine === 'valkey' ? 'RDB' : 'pg_dump' }}</button></div></div>
2525
+ <o-svc-dumps [d]="d" [projectKey]="key()" [dumps]="!!engine()?.dumps" (done)="notice.set($event); refresh()" (failed)="error.set($event)" />
1900
2526
  </div>
1901
2527
  </div>
1902
2528
  }
@@ -1907,10 +2533,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1907
2533
  <o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
1908
2534
  }
1909
2535
  @case ('console') {
1910
- <div class="o-flex o-wrap"><span class="o-chip o-grn">Read-only</span><span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as console_ro · audited</span><b class="o-ph-tag o-ga">GA</b><span class="o-r"></span>
1911
- @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>
1912
- <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>
1913
- <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>
2536
+ <o-svc-console [d]="d" [projectKey]="key()" [kind]="engine()?.console ?? 'sql'" />
1914
2537
  }
1915
2538
  @case ('settings') {
1916
2539
  <div class="o-col" style="gap:14px;max-width:820px">
@@ -1925,7 +2548,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1925
2548
  </div>
1926
2549
  <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>
1927
2550
  </div>
1928
- <div class="o-card"><div class="o-ch"><b>High availability</b><b class="o-ph-tag o-ga">GA</b></div><div class="o-row"><span style="flex:1">Standby node with automatic failover</span><button type="button" class="o-tog" disabled aria-label="Standby (GA)"></button></div><div class="o-row"><span style="flex:1">Read replicas</span><span class="o-faint o-sm">0</span><button type="button" class="o-btn o-xs" disabled>+ Replica</button></div></div>
2551
+ <div class="o-card" data-testid="svc-ha">
2552
+ <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>
2553
+ @switch (engine()?.ha) {
2554
+ @case ('standby') {
2555
+ <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>
2556
+ <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>
2557
+ @if (d.canManage) {
2558
+ <button type="button" class="o-btn o-xs" (click)="topology(d, d.standby, d.replicas - step(d))" [disabled]="busy() || d.replicas === 0">−</button>
2559
+ <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>
2560
+ }</div>
2561
+ <div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
2562
+ }
2563
+ @case ('cluster') {
2564
+ <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>
2565
+ <div class="o-cf">Each node is billed at the plan's price · {{ nodesPrice(d) }}/mo now</div>
2566
+ }
2567
+ @default {
2568
+ <div class="o-row"><span style="flex:1">Replica with failover</span><b class="o-ph-tag o-later">Later</b></div>
2569
+ <div class="o-cf">{{ d.engineName }} runs one node for now; daily snapshots keep its data.</div>
2570
+ }
2571
+ }
2572
+ </div>
1929
2573
  <div class="o-card">
1930
2574
  <div class="o-ch"><b>Networking</b></div>
1931
2575
  <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>
@@ -1946,9 +2590,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1946
2590
  <span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
1947
2591
  </div>
1948
2592
  </div>
1949
- <div class="o-card"><div class="o-ch"><b>Engine parameters</b><b class="o-ph-tag o-ga">GA</b><span class="o-r">validated subset</span></div><div class="o-tbl"><div class="o-tbody">
1950
- @for (p of params(); track p[0]) {<div class="o-tr"><div class="o-td o-mono o-sm">{{ p[0] }}</div><div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [value]="p[1]" disabled [attr.aria-label]="p[0]" /></div><div class="o-td"><span class="o-chip" [class.o-amb]="p[2] === 'restart'">{{ p[2] }}</span></div></div>}
1951
- </div></div></div>
2593
+ <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">
2594
+ @for (p of d.parameters; track p.name) {<div class="o-tr">
2595
+ <div class="o-td o-mono o-sm" [title]="p.description">{{ p.name }}</div>
2596
+ <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>
2597
+ <div class="o-td"><span class="o-chip" [class.o-amb]="p.restart">{{ p.restart ? 'restart' : 'live' }}</span></div>
2598
+ <div class="o-td o-faint o-xs o-ell" style="max-width:260px">{{ p.description }}</div>
2599
+ </div>}
2600
+ </div></div>
2601
+ @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>}
2602
+ </div>
1952
2603
  @if (d.canManage && !d.deletedAt) {
1953
2604
  <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>
1954
2605
  }
@@ -2007,5 +2658,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2007
2658
  * Generated bundle index. Do not edit.
2008
2659
  */
2009
2660
 
2010
- 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 };
2661
+ 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 };
2011
2662
  //# sourceMappingURL=chisquare-tech-origine-ngx-apps-services.mjs.map