@chisquare-tech/origine-ngx-apps-services 0.1.1 → 0.2.1

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