@chisquare-tech/origine-ngx-apps-services 0.1.1 → 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) {
@@ -662,9 +721,10 @@ class ServicesNewPage {
662
721
  <span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
663
722
  <span class="o-k">Region</span><span>{{ region() }}</span>
664
723
  <span class="o-k">Network</span><span>Private · TLS</span>
665
- <span class="o-k">Backups</span><span>{{ engine()?.id === 'mysql' ? 'Hourly' : '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>
666
726
  </div>
667
- <div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md">{{ plan() ? price(plan()!.priceCents) + '/mo' : '' }}</b></div>
727
+ <div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md" data-testid="svc-monthly">{{ plan() ? price(plan()!.priceCents * nodes()) + '/mo' : '' }}</b></div>
668
728
  <span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
669
729
  </div>
670
730
  </div>
@@ -725,7 +785,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
725
785
  @if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
726
786
  <div class="o-g2">
727
787
  <div class="o-fld"><label>Region</label><input class="o-in" [value]="region()" readonly aria-label="Region" /></div>
728
- <div class="o-fld"><label>High availability <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>
729
793
  </div>
730
794
  }
731
795
  @if (step() === 3) {
@@ -781,9 +845,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
781
845
  <span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
782
846
  <span class="o-k">Region</span><span>{{ region() }}</span>
783
847
  <span class="o-k">Network</span><span>Private · TLS</span>
784
- <span class="o-k">Backups</span><span>{{ engine()?.id === 'mysql' ? 'Hourly' : '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>
785
850
  </div>
786
- <div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md">{{ plan() ? price(plan()!.priceCents) + '/mo' : '' }}</b></div>
851
+ <div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md" data-testid="svc-monthly">{{ plan() ? price(plan()!.priceCents * nodes()) + '/mo' : '' }}</b></div>
787
852
  <span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
788
853
  </div>
789
854
  </div>
@@ -792,6 +857,396 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
792
857
  }]
793
858
  }], ctorParameters: () => [] });
794
859
 
860
+ // The browser console (SVC-17; prototype "services" › Console): SQL for Postgres and MySQL, run
861
+ // as the owner in a read-only transaction unless "Allow writes" is on, with the 1,000-row limit
862
+ // and 5 s statement timeout; an inspector for Valkey keys, NATS streams and RabbitMQ queues.
863
+ // Everything run here is in the audit log.
864
+ class ServiceConsoleTab {
865
+ constructor() {
866
+ this.api = inject(ServicesApi);
867
+ this.d = input.required(...(ngDevMode ? [{ debugName: "d" }] : /* istanbul ignore next */ []));
868
+ this.projectKey = input.required(...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
869
+ this.kind = input('sql', ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
870
+ this.sql = computed(() => this.kind() === 'sql', ...(ngDevMode ? [{ debugName: "sql" }] : /* istanbul ignore next */ []));
871
+ this.running = computed(() => ['Running', 'Updating', 'Degraded'].includes(this.d().state), ...(ngDevMode ? [{ debugName: "running" }] : /* istanbul ignore next */ []));
872
+ this.runsAs = computed(() => this.result()?.runsAs || this.d().connection?.user || 'the owner', ...(ngDevMode ? [{ debugName: "runsAs" }] : /* istanbul ignore next */ []));
873
+ this.stmt = signal('SELECT now();', ...(ngDevMode ? [{ debugName: "stmt" }] : /* istanbul ignore next */ []));
874
+ this.db = signal('', ...(ngDevMode ? [{ debugName: "db" }] : /* istanbul ignore next */ []));
875
+ this.write = signal(false, ...(ngDevMode ? [{ debugName: "write" }] : /* istanbul ignore next */ []));
876
+ this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
877
+ this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
878
+ this.result = signal(null, ...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
879
+ this.pattern = signal('', ...(ngDevMode ? [{ debugName: "pattern" }] : /* istanbul ignore next */ []));
880
+ this.list = signal(null, ...(ngDevMode ? [{ debugName: "list" }] : /* istanbul ignore next */ []));
881
+ this.last = '';
882
+ }
883
+ inspectorLine() {
884
+ return this.kind() === 'keys' ? 'Keys by SCAN, values up to 100 items' : this.kind() === 'streams' ? 'Streams, consumers and the last 20 messages' : 'Queues with depth, unacknowledged and consumers';
885
+ }
886
+ runSql() {
887
+ const statement = this.stmt().trim();
888
+ if (!statement || this.busy())
889
+ return;
890
+ this.busy.set(true);
891
+ this.error.set('');
892
+ this.api.query(this.projectKey(), this.d().id, { database: this.db() || this.d().databases[0]?.name, statement, write: this.write() }).subscribe({
893
+ next: (r) => {
894
+ this.busy.set(false);
895
+ this.result.set(r);
896
+ },
897
+ error: (e) => {
898
+ this.busy.set(false);
899
+ this.result.set(null);
900
+ this.error.set(apiMessage(e, 'The statement could not run.'));
901
+ },
902
+ });
903
+ }
904
+ resultLine(r) {
905
+ const n = r.rows.length;
906
+ const rows = r.columns.length ? `${n.toLocaleString()} row${n === 1 ? '' : 's'}${r.truncated ? ' (limit reached)' : ''}` : `${r.command || 'OK'}${r.rowsAffected ? ' · ' + r.rowsAffected + ' rows' : ''}`;
907
+ return `${rows} · ${r.ms} ms · ${r.readOnly ? 'read-only' : 'committed'}`;
908
+ }
909
+ inspect(cursor = '', item = '') {
910
+ if (this.busy())
911
+ return;
912
+ this.busy.set(true);
913
+ this.error.set('');
914
+ this.api.inspect(this.projectKey(), this.d().id, { pattern: this.pattern(), cursor, item }).subscribe({
915
+ next: (r) => {
916
+ this.busy.set(false);
917
+ if (item) {
918
+ this.list.update((l) => (l ? { ...l, detail: r.detail } : r));
919
+ }
920
+ else if (cursor) {
921
+ this.list.update((l) => ({ ...r, items: [...(l?.items ?? []), ...r.items] }));
922
+ }
923
+ else {
924
+ this.list.set(r);
925
+ }
926
+ },
927
+ error: (e) => {
928
+ this.busy.set(false);
929
+ this.error.set(apiMessage(e, 'The inspector could not read the service.'));
930
+ },
931
+ });
932
+ }
933
+ open(it) {
934
+ const k = this.kind();
935
+ this.last = k === 'keys' ? it['key'] : k === 'streams' ? it['name'] : `${it['vhost'] ?? '/'}/${it['name']}`;
936
+ this.inspect('', this.last);
937
+ }
938
+ label(it) {
939
+ return String(this.kind() === 'keys' ? it['key'] : it['name']);
940
+ }
941
+ note(it) {
942
+ switch (this.kind()) {
943
+ case 'keys':
944
+ return `${it['type']}${it['ttl'] > 0 ? ' · ttl ' + it['ttl'] + ' s' : ''}`;
945
+ case 'streams':
946
+ return `${Number(it['messages'] ?? 0).toLocaleString()} msgs · ${it['consumers'] ?? 0} consumers${it['kv'] ? ' · KV' : ''}`;
947
+ }
948
+ return `${it['vhost'] ?? '/'} · ${Number(it['messages'] ?? 0).toLocaleString()} msgs · ${it['messages_unacknowledged'] ?? 0} unacked`;
949
+ }
950
+ pretty(v) {
951
+ return JSON.stringify(v, null, 2);
952
+ }
953
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceConsoleTab, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
954
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceConsoleTab, isStandalone: true, selector: "o-svc-console", inputs: { d: { classPropertyName: "d", publicName: "d", isSignal: true, isRequired: true, transformFunction: null }, projectKey: { classPropertyName: "projectKey", publicName: "projectKey", isSignal: true, isRequired: true, transformFunction: null }, kind: { classPropertyName: "kind", publicName: "kind", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
955
+ @if (sql()) {
956
+ <div class="o-flex o-wrap">
957
+ <span class="o-chip" [class.o-grn]="!write()" [class.o-amb]="write()" data-testid="svc-console-mode">{{ write() ? 'Read-write' : 'Read-only' }}</span>
958
+ <span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as {{ runsAs() }} · audited</span>
959
+ <span class="o-r"></span>
960
+ @if (d().canManage) {<label class="o-flex o-sm" style="gap:6px"><input type="checkbox" [ngModel]="write()" (ngModelChange)="write.set($event)" data-testid="svc-console-write" /> Allow writes</label>}
961
+ @if (d().databases.length) {
962
+ <select class="o-in o-sm" style="width:auto" aria-label="Database" [ngModel]="db()" (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
+
795
1250
  const COLOUR = { ERROR: 'var(--o-bad-solid)', FATAL: 'var(--o-bad-solid)', PANIC: 'var(--o-bad-solid)', WARNING: 'var(--o-run)' };
796
1251
  // The instance page's Logs tab (prototype "svc · logs"): the engine's own log from its pods —
797
1252
  // all, errors, the slow log, connections — newest first, refreshed every 10 s.
@@ -1074,16 +1529,11 @@ const TABS = [
1074
1529
  { id: 'overview', label: 'Overview' }, { id: 'users', label: 'Databases and users' }, { id: 'backups', label: 'Backups' }, { id: 'metrics', label: 'Metrics' },
1075
1530
  { id: 'logs', label: 'Logs' }, { id: 'console', label: 'Console' }, { id: 'settings', label: 'Settings' },
1076
1531
  ];
1077
- /** Engine parameters the prototype lists (validated subset, GA). */
1078
- const PARAMS = {
1079
- postgres: [['max_connections', '100', 'restart'], ['work_mem', '4MB', 'live'], ['log_min_duration_statement', '250ms', 'live']],
1080
- mysql: [['max_connections', '151', 'restart'], ['long_query_time', '0.25', 'live'], ['innodb_buffer_pool_size', 'auto', 'restart']],
1081
- valkey: [['maxmemory-policy', 'allkeys-lru', 'live'], ['timeout', '0', 'live'], ['slowlog-log-slower-than', '10000', 'live']],
1082
- };
1083
1532
  // /{org}/{project}/services/:id (prototype "services"): one instance — its connection details and
1084
1533
  // reveal, attached apps, databases and users, backups and point-in-time restore, metrics and engine
1085
- // logs read from the cell, and settings (resize, networking, maintenance, delete). Console, HA and
1086
- // 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.
1087
1537
  class ServicePage {
1088
1538
  constructor() {
1089
1539
  this.host = inject(ServicesHost);
@@ -1119,6 +1569,13 @@ class ServicePage {
1119
1569
  this.attApp = signal('', ...(ngDevMode ? [{ debugName: "attApp" }] : /* istanbul ignore next */ []));
1120
1570
  this.attEnv = signal('production', ...(ngDevMode ? [{ debugName: "attEnv" }] : /* istanbul ignore next */ []));
1121
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 */ []));
1122
1579
  this.addingDb = signal(false, ...(ngDevMode ? [{ debugName: "addingDb" }] : /* istanbul ignore next */ []));
1123
1580
  this.dbName = signal('', ...(ngDevMode ? [{ debugName: "dbName" }] : /* istanbul ignore next */ []));
1124
1581
  this.addingUser = signal(false, ...(ngDevMode ? [{ debugName: "addingUser" }] : /* istanbul ignore next */ []));
@@ -1134,11 +1591,10 @@ class ServicePage {
1134
1591
  this.envVar = computed(() => this.engine()?.envVar ?? 'DATABASE_URL', ...(ngDevMode ? [{ debugName: "envVar" }] : /* istanbul ignore next */ []));
1135
1592
  this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
1136
1593
  this.region = computed(() => this.host.project()?.region || 'your region', ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
1137
- this.params = computed(() => PARAMS[this.d()?.engine ?? ''] ?? [], ...(ngDevMode ? [{ debugName: "params" }] : /* istanbul ignore next */ []));
1138
1594
  this.conn = computed(() => this.revealed()?.connection ?? this.d()?.connection ?? null, ...(ngDevMode ? [{ debugName: "conn" }] : /* istanbul ignore next */ []));
1139
1595
  this.snippetKeys = computed(() => {
1140
1596
  const s = this.conn()?.snippets ?? {};
1141
- 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);
1142
1598
  }, ...(ngDevMode ? [{ debugName: "snippetKeys" }] : /* istanbul ignore next */ []));
1143
1599
  this.backups = computed(() => [...(this.d()?.backups ?? [])].sort((a, b) => (b.startedAt ?? '9').localeCompare(a.startedAt ?? '9')), ...(ngDevMode ? [{ debugName: "backups" }] : /* istanbul ignore next */ []));
1144
1600
  this.attEnvs = computed(() => {
@@ -1197,6 +1653,11 @@ class ServicePage {
1197
1653
  const q = fig('queries');
1198
1654
  out.push({ label: 'Queries / s', value: q?.rate != null ? num(q.rate, 1) : '–', sub: `slow ${num(fig('slow_queries')?.value)} since start` });
1199
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
+ }
1200
1661
  else {
1201
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)}` });
1202
1663
  }
@@ -1294,7 +1755,7 @@ class ServicePage {
1294
1755
  return `${cpu} / ${ram} / ${d.diskGb} GB`;
1295
1756
  }
1296
1757
  snippetLabel(k) {
1297
- return { uri: 'URI', node: 'Node' }[k] ?? k;
1758
+ return { uri: 'URI', node: 'Node', cli: 'origine CLI', nats: 'nats' }[k] ?? k;
1298
1759
  }
1299
1760
  attState(a) {
1300
1761
  return a.state === 'live' ? 'Injected' : a.state === 'failed' ? 'Failed' : 'Redeploying…';
@@ -1364,9 +1825,10 @@ class ServicePage {
1364
1825
  }
1365
1826
  attach(d) {
1366
1827
  const app = this.attApp().trim();
1367
- 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) => {
1368
1830
  this.attachOpen.set(false);
1369
- 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.`);
1370
1832
  }, 'The service could not be attached.');
1371
1833
  }
1372
1834
  detach(d, a) {
@@ -1476,6 +1938,70 @@ class ServicePage {
1476
1938
  this.notice.set(`${d.name} is scheduled for deletion · undo within 24 hours.`);
1477
1939
  }, 'The service could not be deleted.');
1478
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
+ }
1479
2005
  undelete(d) {
1480
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.');
1481
2007
  }
@@ -1547,7 +2073,7 @@ class ServicePage {
1547
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>
1548
2074
  @for (a of d.attachments; track a.id) {
1549
2075
  <div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
1550
- <span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
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>}
1551
2077
  <span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
1552
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>}
1553
2079
  </div>
@@ -1559,6 +2085,8 @@ class ServicePage {
1559
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" />}
1560
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>
1561
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>
1562
2090
  <span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
1563
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>
1564
2092
  </div>
@@ -1577,6 +2105,32 @@ class ServicePage {
1577
2105
  }
1578
2106
  @case ('users') {
1579
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 {
1580
2134
  <div class="o-card">
1581
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>
1582
2136
  @if (addingDb()) {
@@ -1587,6 +2141,7 @@ class ServicePage {
1587
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>
1588
2142
  } @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
1589
2143
  </div>
2144
+ }
1590
2145
  <div class="o-card">
1591
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>
1592
2147
  @if (addingUser()) {
@@ -1601,7 +2156,7 @@ class ServicePage {
1601
2156
  <span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
1602
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>
1603
2158
  }
1604
- <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>
1605
2160
  </div>
1606
2161
  </div>
1607
2162
  @if (d.engine === 'postgres') {
@@ -1631,7 +2186,7 @@ class ServicePage {
1631
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>
1632
2187
  </div>
1633
2188
  } @else {
1634
- <div class="o-card o-cb o-col" style="gap:6px"><b>Restore a snapshot</b><span class="o-hint">{{ d.engineName }} restores from a daily 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>
1635
2190
  }
1636
2191
  <div class="o-card o-cb o-form">
1637
2192
  <b>Schedule</b>
@@ -1644,7 +2199,7 @@ class ServicePage {
1644
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>
1645
2200
  </div>
1646
2201
  </div>
1647
- <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)" />
1648
2203
  </div>
1649
2204
  </div>
1650
2205
  }
@@ -1655,10 +2210,7 @@ class ServicePage {
1655
2210
  <o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
1656
2211
  }
1657
2212
  @case ('console') {
1658
- <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>
1659
- @if (d.databases.length) {<select class="o-in o-sm" style="width:auto" aria-label="Database" disabled>@for (db of d.databases; track db.name) {<option>{{ db.name }}</option>}</select>}</div>
1660
- <div class="o-card"><textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" disabled aria-label="Query">{{ d.engine === 'valkey' ? 'INFO keyspace' : 'SELECT * FROM … LIMIT 50' }}</textarea><div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" disabled>Run</button></div></div>
1661
- <span class="o-faint o-sm">The console arrives at GA. Until then, connect with the snippets on Overview from an attached app, or reveal the password.</span>
2213
+ <o-svc-console [d]="d" [projectKey]="key()" [kind]="engine()?.console ?? 'sql'" />
1662
2214
  }
1663
2215
  @case ('settings') {
1664
2216
  <div class="o-col" style="gap:14px;max-width:820px">
@@ -1673,7 +2225,28 @@ class ServicePage {
1673
2225
  </div>
1674
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>
1675
2227
  </div>
1676
- <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>
1677
2250
  <div class="o-card">
1678
2251
  <div class="o-ch"><b>Networking</b></div>
1679
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>
@@ -1694,9 +2267,16 @@ class ServicePage {
1694
2267
  <span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
1695
2268
  </div>
1696
2269
  </div>
1697
- <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">
1698
- @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>}
1699
- </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>
1700
2280
  @if (d.canManage && !d.deletedAt) {
1701
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>
1702
2282
  }
@@ -1741,13 +2321,13 @@ class ServicePage {
1741
2321
  } @else {
1742
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>
1743
2323
  }
1744
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ServiceMetricsTab, selector: "o-svc-metrics", inputs: ["readings", "slowLines", "engine", "projectKey", "service", "dashboard"] }, { kind: "component", type: ServiceLogsTab, selector: "o-svc-logs", inputs: ["projectKey", "id", "service", "environment", "debugLogs"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
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 }); }
1745
2325
  }
1746
2326
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicePage, decorators: [{
1747
2327
  type: Component,
1748
2328
  args: [{
1749
2329
  selector: 'o-service',
1750
- imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab],
2330
+ imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab, ServiceConsoleTab, ServiceDumpsCard],
1751
2331
  changeDetection: ChangeDetectionStrategy.OnPush,
1752
2332
  template: `
1753
2333
  @if (d(); as d) {
@@ -1816,7 +2396,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1816
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>
1817
2397
  @for (a of d.attachments; track a.id) {
1818
2398
  <div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
1819
- <span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
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>}
1820
2400
  <span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
1821
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>}
1822
2402
  </div>
@@ -1828,6 +2408,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1828
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" />}
1829
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>
1830
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>
1831
2413
  <span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
1832
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>
1833
2415
  </div>
@@ -1846,6 +2428,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1846
2428
  }
1847
2429
  @case ('users') {
1848
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 {
1849
2457
  <div class="o-card">
1850
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>
1851
2459
  @if (addingDb()) {
@@ -1856,6 +2464,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1856
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>
1857
2465
  } @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
1858
2466
  </div>
2467
+ }
1859
2468
  <div class="o-card">
1860
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>
1861
2470
  @if (addingUser()) {
@@ -1870,7 +2479,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1870
2479
  <span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
1871
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>
1872
2481
  }
1873
- <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>
1874
2483
  </div>
1875
2484
  </div>
1876
2485
  @if (d.engine === 'postgres') {
@@ -1900,7 +2509,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1900
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>
1901
2510
  </div>
1902
2511
  } @else {
1903
- <div class="o-card o-cb o-col" style="gap:6px"><b>Restore a snapshot</b><span class="o-hint">{{ d.engineName }} restores from a daily 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>
1904
2513
  }
1905
2514
  <div class="o-card o-cb o-form">
1906
2515
  <b>Schedule</b>
@@ -1913,7 +2522,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1913
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>
1914
2523
  </div>
1915
2524
  </div>
1916
- <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)" />
1917
2526
  </div>
1918
2527
  </div>
1919
2528
  }
@@ -1924,10 +2533,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1924
2533
  <o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
1925
2534
  }
1926
2535
  @case ('console') {
1927
- <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>
1928
- @if (d.databases.length) {<select class="o-in o-sm" style="width:auto" aria-label="Database" disabled>@for (db of d.databases; track db.name) {<option>{{ db.name }}</option>}</select>}</div>
1929
- <div class="o-card"><textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" disabled aria-label="Query">{{ d.engine === 'valkey' ? 'INFO keyspace' : 'SELECT * FROM … LIMIT 50' }}</textarea><div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" disabled>Run</button></div></div>
1930
- <span class="o-faint o-sm">The console arrives at GA. Until then, connect with the snippets on Overview from an attached app, or reveal the password.</span>
2536
+ <o-svc-console [d]="d" [projectKey]="key()" [kind]="engine()?.console ?? 'sql'" />
1931
2537
  }
1932
2538
  @case ('settings') {
1933
2539
  <div class="o-col" style="gap:14px;max-width:820px">
@@ -1942,7 +2548,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1942
2548
  </div>
1943
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>
1944
2550
  </div>
1945
- <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>
1946
2573
  <div class="o-card">
1947
2574
  <div class="o-ch"><b>Networking</b></div>
1948
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>
@@ -1963,9 +2590,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1963
2590
  <span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
1964
2591
  </div>
1965
2592
  </div>
1966
- <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">
1967
- @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>}
1968
- </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>
1969
2603
  @if (d.canManage && !d.deletedAt) {
1970
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>
1971
2605
  }
@@ -2024,5 +2658,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2024
2658
  * Generated bundle index. Do not edit.
2025
2659
  */
2026
2660
 
2027
- export { DAYS, ENGINE_COLOUR, LIFECYCLE, PRESETS, ServiceLogsTab, ServiceMetricsTab, ServicePage, ServicesApi, ServicesHost, ServicesListPage, ServicesNewPage, apiMessage, backupKind, backupsLine, bytes, fromLocalInput, hhmm, hhmmss, money, moving, num, pct, presetChip, ratio, shortDate, spark, sparkPath, stateChip, stateDot, toLocalInput, utcHour, when };
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 };
2028
2662
  //# sourceMappingURL=chisquare-tech-origine-ngx-apps-services.mjs.map