@chisquare-tech/origine-ngx-apps-services 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2011 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, Injectable, signal, computed, effect, untracked, DestroyRef, ChangeDetectionStrategy, Component, input } from '@angular/core';
3
+ import { ApiService } from '@thescaffold/ngx-core';
4
+ import { map, forkJoin, of } from 'rxjs';
5
+ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
6
+ import * as i1 from '@angular/forms';
7
+ import { FormsModule } from '@angular/forms';
8
+ import { catchError } from 'rxjs/operators';
9
+
10
+ /**
11
+ * What the Services screens need from the app that mounts them (cloud/web provides it, as it
12
+ * provides DesignHost, CodeHost, DeployHost and DebugHost): where the person is, links inside the
13
+ * project, and the project's apps (deploy's, for "Attach to app"; the module never calls deploy).
14
+ */
15
+ class ServicesHost {
16
+ }
17
+
18
+ const S = 'v1/services';
19
+ const enc = encodeURIComponent;
20
+ const data = (r) => r?.data;
21
+ function apiMessage(err, fallback = 'Something went wrong. Try again.') {
22
+ return err?.error?.message || err?.body?.message || err?.message || fallback;
23
+ }
24
+ class ServicesApi {
25
+ constructor() {
26
+ this.api = inject(ApiService);
27
+ }
28
+ i(project, id = '', path = '') {
29
+ return `${S}/project/${enc(project)}/instance${id ? '/' + enc(id) : ''}${path ? '/' + path : ''}`;
30
+ }
31
+ catalog() {
32
+ return this.api.get(`${S}/catalog`).pipe(map((r) => data(r)));
33
+ }
34
+ list(project) {
35
+ return this.api.get(this.i(project)).pipe(map((r) => data(r) ?? { items: [] }));
36
+ }
37
+ get(project, id) {
38
+ return this.api.get(this.i(project, id)).pipe(map((r) => data(r)));
39
+ }
40
+ create(project, body) {
41
+ return this.api.post(this.i(project), body).pipe(map((r) => data(r)));
42
+ }
43
+ update(project, id, body) {
44
+ return this.api.patch(this.i(project, id), body).pipe(map((r) => data(r)));
45
+ }
46
+ remove(project, id, confirm, finalBackup) {
47
+ return this.api.post(this.i(project, id, 'delete'), { confirm, finalBackup }).pipe(map((r) => data(r)));
48
+ }
49
+ undelete(project, id) {
50
+ return this.api.post(this.i(project, id, 'undelete'), {}).pipe(map((r) => data(r)));
51
+ }
52
+ reveal(project, id, user = '') {
53
+ return this.api.post(this.i(project, id, 'reveal'), user ? { user } : {}).pipe(map((r) => data(r)));
54
+ }
55
+ addDatabase(project, id, name) {
56
+ return this.api.post(this.i(project, id, 'database'), { name });
57
+ }
58
+ removeDatabase(project, id, name) {
59
+ return this.api.delete(this.i(project, id, 'database/' + enc(name)));
60
+ }
61
+ addUser(project, id, name, preset) {
62
+ return this.api.post(this.i(project, id, 'user'), { name, preset });
63
+ }
64
+ removeUser(project, id, name) {
65
+ return this.api.delete(this.i(project, id, 'user/' + enc(name)));
66
+ }
67
+ rotate(project, id, user = '', graceMinutes) {
68
+ return this.api.post(this.i(project, id, 'rotate'), { user, graceMinutes }).pipe(map((r) => data(r)));
69
+ }
70
+ retire(project, id, rotation) {
71
+ return this.api.post(this.i(project, id, 'rotation/' + enc(rotation) + '/retire'), {});
72
+ }
73
+ backupNow(project, id) {
74
+ return this.api.post(this.i(project, id, 'backup'), {});
75
+ }
76
+ deleteBackup(project, id, name) {
77
+ return this.api.delete(this.i(project, id, 'backup/' + enc(name)));
78
+ }
79
+ restore(project, id, body) {
80
+ return this.api.post(this.i(project, id, 'restore'), body).pipe(map((r) => data(r)));
81
+ }
82
+ attach(project, id, body) {
83
+ return this.api.post(this.i(project, id, 'attachment'), body).pipe(map((r) => data(r)));
84
+ }
85
+ detach(project, id, attachment) {
86
+ return this.api.delete(this.i(project, id, 'attachment/' + enc(attachment)));
87
+ }
88
+ swap(project, id, from) {
89
+ return this.api.post(this.i(project, id, 'swap'), { from });
90
+ }
91
+ metrics(project, id) {
92
+ return this.api.get(this.i(project, id, 'metrics')).pipe(map((r) => data(r)));
93
+ }
94
+ logs(project, id, kind, limit = 200) {
95
+ return this.api.get(this.i(project, id, 'logs'), { kind, limit }).pipe(map((r) => data(r)));
96
+ }
97
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesApi, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
98
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesApi, providedIn: 'root' }); }
99
+ }
100
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesApi, decorators: [{
101
+ type: Injectable,
102
+ args: [{ providedIn: 'root' }]
103
+ }] });
104
+
105
+ // Small helpers the Services screens share; colours and wording follow the prototype.
106
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
107
+ /** The prototype's engine squares: Postgres accent, MySQL amber, Valkey red. */
108
+ const ENGINE_COLOUR = {
109
+ postgres: 'var(--o-acc)', mysql: 'var(--o-run)', valkey: 'var(--o-bad-solid)', nats: 'var(--o-ok)', rabbitmq: 'var(--o-run)',
110
+ };
111
+ /** State chips (prototype stCls): green running, blue in motion, amber attention, red failing or going. */
112
+ const STATE_CLASS = {
113
+ Running: 'o-grn', Provisioning: 'o-blu', Restoring: 'o-blu', Updating: 'o-blu', Maintenance: 'o-amb', Degraded: 'o-amb', Suspended: 'o-amb',
114
+ Failed: 'o-red', Deleting: 'o-red', Deleted: 'o-red',
115
+ };
116
+ function stateChip(s) {
117
+ return 'o-chip ' + (STATE_CLASS[s] ?? '');
118
+ }
119
+ function stateDot(s) {
120
+ const c = STATE_CLASS[s];
121
+ return 'o-dot ' + (c === 'o-grn' ? 'o-grn' : c === 'o-blu' ? 'o-blu' : c === 'o-red' ? 'o-red' : 'o-amb');
122
+ }
123
+ /** States that change by themselves: the screens poll while one shows. */
124
+ function moving(s) {
125
+ return ['Provisioning', 'Updating', 'Restoring', 'Deleting', 'Maintenance'].includes(s);
126
+ }
127
+ const LIFECYCLE = 'Provisioning · Running · Updating · Maintenance · Restoring · Degraded · Failed · Deleting';
128
+ function money(cents) {
129
+ const d = cents / 100;
130
+ return '$' + (Number.isInteger(d) ? d.toFixed(0) : d.toFixed(2));
131
+ }
132
+ function bytes(v) {
133
+ if (v == null || Number.isNaN(v))
134
+ return '–';
135
+ const u = ['B', 'KB', 'MB', 'GB', 'TB'];
136
+ let i = 0;
137
+ let x = v;
138
+ while (x >= 1024 && i < u.length - 1) {
139
+ x /= 1024;
140
+ i++;
141
+ }
142
+ return `${x >= 100 || i === 0 ? x.toFixed(0) : x.toFixed(1)} ${u[i]}`;
143
+ }
144
+ function num(v, digits = 0) {
145
+ if (v == null || Number.isNaN(v))
146
+ return '–';
147
+ return v.toLocaleString('en-US', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
148
+ }
149
+ function pct(v) {
150
+ if (v == null || Number.isNaN(v))
151
+ return '–';
152
+ return `${v < 10 && v > 0 ? v.toFixed(1) : Math.round(v)}%`;
153
+ }
154
+ /** "42 / 100" for a count gauge. */
155
+ function ratio(g) {
156
+ if (!g || g.used == null)
157
+ return '–';
158
+ return `${num(g.used)} / ${num(g.limit)}`;
159
+ }
160
+ function pad(n) {
161
+ return String(n).padStart(2, '0');
162
+ }
163
+ function hhmm(iso) {
164
+ const t = new Date(iso);
165
+ return Number.isNaN(t.getTime()) ? '' : `${pad(t.getHours())}:${pad(t.getMinutes())}`;
166
+ }
167
+ function hhmmss(iso) {
168
+ const t = new Date(iso);
169
+ return Number.isNaN(t.getTime()) ? '' : `${hhmm(iso)}:${pad(t.getSeconds())}`;
170
+ }
171
+ function shortDate(iso) {
172
+ if (!iso)
173
+ return '';
174
+ const t = new Date(iso);
175
+ return Number.isNaN(t.getTime()) ? '' : `${MONTHS[t.getMonth()]} ${t.getDate()}`;
176
+ }
177
+ /** "Today 03:00", "Yesterday 03:00", "Oct 26, 03:00". */
178
+ function when(iso, now = Date.now()) {
179
+ if (!iso)
180
+ return '';
181
+ const t = Date.parse(iso);
182
+ if (Number.isNaN(t))
183
+ return '';
184
+ const d = new Date(t);
185
+ if (d.toDateString() === new Date(now).toDateString())
186
+ return `Today ${hhmm(iso)}`;
187
+ if (d.toDateString() === new Date(now - 86400000).toDateString())
188
+ return `Yesterday ${hhmm(iso)}`;
189
+ return `${shortDate(iso)}, ${hhmm(iso)}`;
190
+ }
191
+ /** The hour as the wizard and schedule show it: "03:00 UTC". */
192
+ function utcHour(h) {
193
+ return `${pad(h)}:00 UTC`;
194
+ }
195
+ /** The list's Backups column: "Today 03:00 · PITR", or when the first one comes. */
196
+ function backupsLine(s) {
197
+ if (!s.lastBackup)
198
+ return s.state === 'Provisioning' ? 'first backup after provisioning' : 'none yet';
199
+ return when(s.lastBackup) + (s.pitr ? ' · PITR' : ' · snapshot');
200
+ }
201
+ function backupKind(b) {
202
+ return b.kind === 'on-demand' ? 'on-demand' : 'scheduled';
203
+ }
204
+ /** <input type="datetime-local"> value (local time) ↔ ISO. */
205
+ function toLocalInput(iso) {
206
+ if (!iso)
207
+ return '';
208
+ const t = new Date(iso);
209
+ if (Number.isNaN(t.getTime()))
210
+ return '';
211
+ return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}T${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}`;
212
+ }
213
+ function fromLocalInput(v) {
214
+ const t = new Date(v);
215
+ return Number.isNaN(t.getTime()) ? '' : t.toISOString();
216
+ }
217
+ /** A small SVG sparkline for values in a w×h box: the line, and the area under it. A flat series sits mid-height. */
218
+ function sparkPath(values, w = 200, h = 60) {
219
+ return spark(values, w, h).line;
220
+ }
221
+ function spark(values, w = 200, h = 60) {
222
+ if (values.length < 2)
223
+ return { line: '', area: '' };
224
+ const min = Math.min(...values);
225
+ const max = Math.max(...values);
226
+ const span = max - min;
227
+ const pts = values.map((v, i) => [(i / (values.length - 1)) * w, span ? h - 4 - ((v - min) / span) * (h - 12) : h / 2]);
228
+ const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
229
+ return { line, area: `${line} L${w},${h} L0,${h} Z` };
230
+ }
231
+ const PRESETS = [
232
+ { id: 'read-only', label: 'read-only' },
233
+ { id: 'read-write', label: 'read-write' },
234
+ { id: 'admin', label: 'admin' },
235
+ ];
236
+ function presetChip(p) {
237
+ return 'o-chip ' + (p === 'admin' || p === 'owner' ? 'o-red' : p === 'read-write' ? 'o-blu' : '');
238
+ }
239
+ const DAYS = [
240
+ { id: 'sun', label: 'Sunday' }, { id: 'mon', label: 'Monday' }, { id: 'tue', label: 'Tuesday' }, { id: 'wed', label: 'Wednesday' },
241
+ { id: 'thu', label: 'Thursday' }, { id: 'fri', label: 'Friday' }, { id: 'sat', label: 'Saturday' },
242
+ ];
243
+
244
+ // /{org}/{project}/services (prototype "services_list"): the project's instances with their state,
245
+ // what they are attached to, their backups and price, and the organization's quota line (SVC-01, 26).
246
+ class ServicesListPage {
247
+ constructor() {
248
+ this.host = inject(ServicesHost);
249
+ this.api = inject(ServicesApi);
250
+ this.router = inject(Router);
251
+ this.list = signal({ items: [] }, ...(ngDevMode ? [{ debugName: "list" }] : /* istanbul ignore next */ []));
252
+ this.loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
253
+ this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
254
+ this.lifecycle = LIFECYCLE;
255
+ this.chip = stateChip;
256
+ this.colour = (e) => ENGINE_COLOUR[e] ?? 'var(--o-line-mid)';
257
+ this.backups = backupsLine;
258
+ this.price = money;
259
+ this.rows = computed(() => this.list().items.filter((s) => !s.deletedAt || s.state === 'Deleting'), ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
260
+ this.canManage = computed(() => this.host.project()?.capabilities.includes('resources.manage') ?? false, ...(ngDevMode ? [{ debugName: "canManage" }] : /* istanbul ignore next */ []));
261
+ this.quotaLine = computed(() => {
262
+ const q = this.list().quota;
263
+ if (!q)
264
+ return '';
265
+ const n = q.limit ? `${q.used} of ${q.limit} instance${q.limit === 1 ? '' : 's'}` : `${q.used} instance${q.used === 1 ? '' : 's'}`;
266
+ return `${n} · ${q.plan} plan${q.onlyPlan ? ' · ' + q.onlyPlan + ' only' : ''}`;
267
+ }, ...(ngDevMode ? [{ debugName: "quotaLine" }] : /* istanbul ignore next */ []));
268
+ // After the wizard: provisioning, then running (with what was injected where).
269
+ this.route = inject(ActivatedRoute);
270
+ this.createdName = signal(this.route.snapshot.queryParamMap.get('created') ?? '', ...(ngDevMode ? [{ debugName: "createdName" }] : /* istanbul ignore next */ []));
271
+ this.createdApp = this.route.snapshot.queryParamMap.get('attached') ?? '';
272
+ this.createdVar = this.route.snapshot.queryParamMap.get('envVar') ?? '';
273
+ this.created = computed(() => {
274
+ const n = this.createdName();
275
+ const s = n ? this.list().items.find((i) => i.name === n) : undefined;
276
+ if (!s)
277
+ return null;
278
+ if (s.state === 'Running') {
279
+ const inj = this.createdApp ? ` · ${this.createdVar} injected into ${this.createdApp}` : '';
280
+ return { running: true, text: `${n} is running${inj} · dashboard and default alert rules created` };
281
+ }
282
+ if (s.state === 'Failed')
283
+ return { running: false, text: `${n} failed to provision: ${s.message || 'see the instance page'}` };
284
+ return { running: false, text: `${n} provisioning · usually under 5 minutes` };
285
+ }, ...(ngDevMode ? [{ debugName: "created" }] : /* istanbul ignore next */ []));
286
+ this.key = '';
287
+ effect(() => {
288
+ const p = this.host.project();
289
+ if (p && p.key !== this.key) {
290
+ this.key = p.key;
291
+ untracked(() => this.load());
292
+ }
293
+ });
294
+ inject(DestroyRef).onDestroy(() => clearTimeout(this.timer));
295
+ }
296
+ load() {
297
+ clearTimeout(this.timer);
298
+ const key = this.key;
299
+ this.api.list(key).subscribe({
300
+ next: (l) => {
301
+ if (key !== this.key)
302
+ return;
303
+ this.list.set(l);
304
+ this.loaded.set(true);
305
+ this.error.set('');
306
+ // Provisioning, resizing, restoring: look again until they settle.
307
+ if (l.items.some((s) => moving(s.state)))
308
+ this.timer = setTimeout(() => this.load(), 5000);
309
+ },
310
+ error: (e) => {
311
+ this.loaded.set(true);
312
+ this.error.set(apiMessage(e, 'Services could not be loaded.'));
313
+ },
314
+ });
315
+ }
316
+ open(v) {
317
+ this.router.navigate(this.host.link('services/' + v.name));
318
+ }
319
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesListPage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
320
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServicesListPage, isStandalone: true, selector: "o-services-list", ngImport: i0, template: `
321
+ <div class="o-page">
322
+ <div class="o-ph">
323
+ <div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · private network · TLS enforced</span><h1 class="o-t">Services</h1></div>
324
+ <div class="o-acts">
325
+ <span class="o-faint o-sm" data-testid="svc-quota">{{ quotaLine() }}</span>
326
+ @if (canManage()) {<a class="o-btn o-dark" [routerLink]="host.link('services/new')" data-testid="svc-create">+ Create service</a>}
327
+ </div>
328
+ </div>
329
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
330
+ @if (created(); as c) {<div class="o-note" [class.o-grn]="c.running" role="status" data-testid="svc-created">{{ c.text }}</div>}
331
+ <div class="o-card" style="overflow-x:auto">
332
+ <div class="o-tbl" style="min-width:860px">
333
+ <div class="o-thead"><div class="o-tr"><div class="o-th">Name</div><div class="o-th">Engine</div><div class="o-th">Plan</div><div class="o-th">Environment</div><div class="o-th">State</div><div class="o-th">Attached to</div><div class="o-th">Backups</div><div class="o-th o-num">Monthly</div></div></div>
334
+ <div class="o-tbody">
335
+ @for (v of rows(); track v.id) {
336
+ <div class="o-tr o-click" (click)="open(v)" (keydown.enter)="open(v)" tabindex="0" role="link" [attr.data-testid]="'svc-row-' + v.name">
337
+ <div class="o-td o-b5 o-mono">{{ v.name }}</div>
338
+ <div class="o-td"><span class="o-flex"><span class="o-sq" [style.background]="colour(v.engine)"></span>{{ v.engineName }} {{ v.version }}</span></div>
339
+ <div class="o-td o-mut o-sm">{{ v.plan }} · {{ v.planLabel.split(' · ').slice(0, 2).join(' · ') }} · {{ v.diskGb }} GB</div>
340
+ <div class="o-td o-mut">{{ v.environment || 'shared' }}</div>
341
+ <div class="o-td"><span [class]="chip(v.state)" [attr.title]="v.message || null">{{ v.state }}</span></div>
342
+ <div class="o-td o-sm">{{ v.attached.length ? v.attached.join(', ') : '—' }}</div>
343
+ <div class="o-td o-faint o-sm">{{ backups(v) }}</div>
344
+ <div class="o-td o-num o-mono">{{ price(v.monthlyCents) }}</div>
345
+ </div>
346
+ } @empty {
347
+ <div class="o-empty">
348
+ @if (loaded()) {
349
+ No services yet. @if (canManage()) {<a [routerLink]="host.link('services/new')">Create a database or cache</a>, attach it to an app, and it is injected as <span class="o-mono">DATABASE_URL</span>.}
350
+ } @else {Loading…}
351
+ </div>
352
+ }
353
+ </div>
354
+ </div>
355
+ </div>
356
+ <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>
359
+ </div>
360
+ <span class="o-faint o-sm">Lifecycle states: {{ lifecycle }}. Open-source engines only.</span>
361
+ </div>
362
+ `, isInline: true, dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
363
+ }
364
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesListPage, decorators: [{
365
+ type: Component,
366
+ args: [{
367
+ selector: 'o-services-list',
368
+ imports: [RouterLink],
369
+ changeDetection: ChangeDetectionStrategy.OnPush,
370
+ template: `
371
+ <div class="o-page">
372
+ <div class="o-ph">
373
+ <div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · private network · TLS enforced</span><h1 class="o-t">Services</h1></div>
374
+ <div class="o-acts">
375
+ <span class="o-faint o-sm" data-testid="svc-quota">{{ quotaLine() }}</span>
376
+ @if (canManage()) {<a class="o-btn o-dark" [routerLink]="host.link('services/new')" data-testid="svc-create">+ Create service</a>}
377
+ </div>
378
+ </div>
379
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
380
+ @if (created(); as c) {<div class="o-note" [class.o-grn]="c.running" role="status" data-testid="svc-created">{{ c.text }}</div>}
381
+ <div class="o-card" style="overflow-x:auto">
382
+ <div class="o-tbl" style="min-width:860px">
383
+ <div class="o-thead"><div class="o-tr"><div class="o-th">Name</div><div class="o-th">Engine</div><div class="o-th">Plan</div><div class="o-th">Environment</div><div class="o-th">State</div><div class="o-th">Attached to</div><div class="o-th">Backups</div><div class="o-th o-num">Monthly</div></div></div>
384
+ <div class="o-tbody">
385
+ @for (v of rows(); track v.id) {
386
+ <div class="o-tr o-click" (click)="open(v)" (keydown.enter)="open(v)" tabindex="0" role="link" [attr.data-testid]="'svc-row-' + v.name">
387
+ <div class="o-td o-b5 o-mono">{{ v.name }}</div>
388
+ <div class="o-td"><span class="o-flex"><span class="o-sq" [style.background]="colour(v.engine)"></span>{{ v.engineName }} {{ v.version }}</span></div>
389
+ <div class="o-td o-mut o-sm">{{ v.plan }} · {{ v.planLabel.split(' · ').slice(0, 2).join(' · ') }} · {{ v.diskGb }} GB</div>
390
+ <div class="o-td o-mut">{{ v.environment || 'shared' }}</div>
391
+ <div class="o-td"><span [class]="chip(v.state)" [attr.title]="v.message || null">{{ v.state }}</span></div>
392
+ <div class="o-td o-sm">{{ v.attached.length ? v.attached.join(', ') : '—' }}</div>
393
+ <div class="o-td o-faint o-sm">{{ backups(v) }}</div>
394
+ <div class="o-td o-num o-mono">{{ price(v.monthlyCents) }}</div>
395
+ </div>
396
+ } @empty {
397
+ <div class="o-empty">
398
+ @if (loaded()) {
399
+ No services yet. @if (canManage()) {<a [routerLink]="host.link('services/new')">Create a database or cache</a>, attach it to an app, and it is injected as <span class="o-mono">DATABASE_URL</span>.}
400
+ } @else {Loading…}
401
+ </div>
402
+ }
403
+ </div>
404
+ </div>
405
+ </div>
406
+ <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>
409
+ </div>
410
+ <span class="o-faint o-sm">Lifecycle states: {{ lifecycle }}. Open-source engines only.</span>
411
+ </div>
412
+ `,
413
+ }]
414
+ }], ctorParameters: () => [] });
415
+
416
+ const NAME = /^[a-z]([-a-z0-9]{0,28}[a-z0-9])?$/;
417
+ const DEFAULT_NAME = { postgres: 'orders-db', mysql: 'ledger-mysql', valkey: 'cache' };
418
+ // /{org}/{project}/services/new (prototype "services_new"): engine and version, size and region,
419
+ // name and environment, with the summary and price beside it (SVC-02…05). Creating returns to the
420
+ // list, where the instance shows Provisioning until the cell reports it Running.
421
+ class ServicesNewPage {
422
+ constructor() {
423
+ this.host = inject(ServicesHost);
424
+ this.api = inject(ServicesApi);
425
+ this.router = inject(Router);
426
+ this.steps = [{ n: 1, label: 'Engine and version' }, { n: 2, label: 'Size and region' }, { n: 3, label: 'Name and environment' }];
427
+ this.hours = Array.from({ length: 24 }, (_, i) => (i + 3) % 24);
428
+ this.keeps = [7, 14, 35];
429
+ this.price = money;
430
+ this.utc = utcHour;
431
+ this.catalog = signal(null, ...(ngDevMode ? [{ debugName: "catalog" }] : /* istanbul ignore next */ []));
432
+ this.quota = signal(null, ...(ngDevMode ? [{ debugName: "quota" }] : /* istanbul ignore next */ []));
433
+ this.apps = signal([], ...(ngDevMode ? [{ debugName: "apps" }] : /* istanbul ignore next */ []));
434
+ this.step = signal(1, ...(ngDevMode ? [{ debugName: "step" }] : /* istanbul ignore next */ []));
435
+ this.engine = signal(null, ...(ngDevMode ? [{ debugName: "engine" }] : /* istanbul ignore next */ []));
436
+ this.version = signal('', ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ []));
437
+ this.plan = signal(null, ...(ngDevMode ? [{ debugName: "plan" }] : /* istanbul ignore next */ []));
438
+ this.name = signal('orders-db', ...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
439
+ this.nameTouched = signal(false, ...(ngDevMode ? [{ debugName: "nameTouched" }] : /* istanbul ignore next */ []));
440
+ this.env = signal('production', ...(ngDevMode ? [{ debugName: "env" }] : /* istanbul ignore next */ []));
441
+ this.attach = signal('', ...(ngDevMode ? [{ debugName: "attach" }] : /* istanbul ignore next */ []));
442
+ this.hour = signal(3, ...(ngDevMode ? [{ debugName: "hour" }] : /* istanbul ignore next */ []));
443
+ this.keep = signal(7, ...(ngDevMode ? [{ debugName: "keep" }] : /* istanbul ignore next */ []));
444
+ this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
445
+ this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
446
+ this.engines = computed(() => this.catalog()?.engines ?? [], ...(ngDevMode ? [{ debugName: "engines" }] : /* istanbul ignore next */ []));
447
+ this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
448
+ this.region = computed(() => this.host.project()?.region || "Your organization's region", ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
449
+ this.versionLabel = computed(() => this.engine()?.versions.find((v) => v.id === this.version())?.label ?? this.version(), ...(ngDevMode ? [{ debugName: "versionLabel" }] : /* istanbul ignore next */ []));
450
+ this.envs = computed(() => {
451
+ const set = new Set(['production', 'staging']);
452
+ for (const a of this.apps())
453
+ a.environments.forEach((e) => set.add(e));
454
+ return [...set, 'shared'];
455
+ }, ...(ngDevMode ? [{ debugName: "envs" }] : /* istanbul ignore next */ []));
456
+ this.hostName = computed(() => {
457
+ const n = this.name() || 'name';
458
+ const e = this.engine()?.id;
459
+ const ns = 's-' + (this.host.project()?.id ?? 'project');
460
+ return e === 'mysql' ? `moco-${n}-primary.${ns}.svc` : e === 'valkey' ? `${n}.${ns}.svc` : `${n}-rw.${ns}.svc`;
461
+ }, ...(ngDevMode ? [{ debugName: "hostName" }] : /* istanbul ignore next */ []));
462
+ this.nameError = computed(() => {
463
+ const n = this.name();
464
+ if (!n)
465
+ return 'A name is required.';
466
+ if (!NAME.test(n))
467
+ return 'Lowercase letters, digits and dashes; starts with a letter; at most 30 characters.';
468
+ return '';
469
+ }, ...(ngDevMode ? [{ debugName: "nameError" }] : /* istanbul ignore next */ []));
470
+ this.key = '';
471
+ effect(() => {
472
+ const p = this.host.project();
473
+ if (p && p.key !== this.key) {
474
+ this.key = p.key;
475
+ untracked(() => this.load(p.key));
476
+ }
477
+ });
478
+ }
479
+ load(key) {
480
+ forkJoin({
481
+ catalog: this.api.catalog(),
482
+ list: this.api.list(key).pipe(catchError(() => of({ items: [], quota: undefined }))),
483
+ apps: this.host.apps(key).pipe(catchError(() => of([]))),
484
+ }).subscribe({
485
+ next: ({ catalog, list, apps }) => {
486
+ this.catalog.set(catalog);
487
+ this.quota.set(list.quota ?? null);
488
+ this.apps.set(apps);
489
+ const first = catalog.engines.find((e) => e.available) ?? null;
490
+ if (first)
491
+ this.pickEngine(first);
492
+ this.plan.set(catalog.plans.find((p) => p.id === (list.quota?.onlyPlan || catalog.plans[0]?.id)) ?? catalog.plans[0] ?? null);
493
+ },
494
+ error: (e) => this.error.set(apiMessage(e, 'The catalogue could not be loaded.')),
495
+ });
496
+ }
497
+ ram(p) {
498
+ const m = /^(\d+)Gi$/.exec(p.memory);
499
+ return m ? `${m[1]} GB` : p.memory;
500
+ }
501
+ allowed(p) {
502
+ const only = this.quota()?.onlyPlan;
503
+ return !only || p.id === only;
504
+ }
505
+ pickEngine(e) {
506
+ if (!e.available) {
507
+ this.error.set(`${e.name} arrives at GA.`);
508
+ return;
509
+ }
510
+ this.error.set('');
511
+ const before = this.engine()?.id;
512
+ this.engine.set(e);
513
+ this.version.set((e.versions.find((v) => v.default) ?? e.versions[0])?.id ?? '');
514
+ // Follow the engine with a fitting default name until the person types one.
515
+ if (!this.nameTouched() || this.name() === DEFAULT_NAME[before ?? ''])
516
+ this.name.set(DEFAULT_NAME[e.id] ?? e.id);
517
+ }
518
+ pickPlan(p) {
519
+ if (this.allowed(p))
520
+ this.plan.set(p);
521
+ }
522
+ go(n) {
523
+ if (n >= 1 && n <= 3)
524
+ this.step.set(n);
525
+ }
526
+ canNext() {
527
+ if (!this.engine() || !this.plan())
528
+ return false;
529
+ return this.step() < 3 || !this.nameError();
530
+ }
531
+ next() {
532
+ if (this.step() < 3) {
533
+ this.go(this.step() + 1);
534
+ return;
535
+ }
536
+ const e = this.engine();
537
+ const p = this.plan();
538
+ if (!e || !p || this.nameError())
539
+ return;
540
+ this.busy.set(true);
541
+ this.error.set('');
542
+ const env = this.env() === 'shared' ? '' : this.env();
543
+ const attach = this.attach().trim();
544
+ this.api
545
+ .create(this.key, {
546
+ 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,
548
+ })
549
+ .subscribe({
550
+ next: (d) => {
551
+ this.busy.set(false);
552
+ this.router.navigate(this.host.link('services'), { queryParams: { created: d.name, attached: attach || null, envVar: attach ? e.envVar : null } });
553
+ },
554
+ error: (err) => {
555
+ this.busy.set(false);
556
+ this.error.set(apiMessage(err, 'The service could not be created.'));
557
+ },
558
+ });
559
+ }
560
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesNewPage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
561
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServicesNewPage, isStandalone: true, selector: "o-services-new", ngImport: i0, template: `
562
+ <div class="o-page o-narrow">
563
+ <div class="o-ph"><div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · ready in under 5 minutes</span><h1 class="o-t">Create a service</h1></div></div>
564
+ <div class="o-flex" style="gap:6px">
565
+ @for (s of steps; track s.n; let i = $index) {
566
+ <button type="button" class="o-flex" style="border:none;background:none;cursor:pointer;gap:8px;padding:0" (click)="go(s.n)" [attr.aria-current]="step() === s.n ? 'step' : null">
567
+ <span class="o-av" [style.background]="s.n <= step() ? 'var(--o-tx)' : 'var(--o-line-soft)'" [style.color]="s.n <= step() ? 'var(--o-acc-fg)' : 'var(--o-mut)'">{{ s.n < step() ? '✓' : s.n }}</span>
568
+ <span class="o-sm" [style.color]="s.n === step() ? 'var(--o-tx)' : 'var(--o-mut)'">{{ s.label }}</span>
569
+ </button>
570
+ @if (i < steps.length - 1) {<span style="flex:1;border-top:1px solid var(--o-line);min-width:30px"></span>}
571
+ }
572
+ </div>
573
+ @if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-new-error">{{ error() }}</div>}
574
+ <div class="o-split" style="grid-template-columns:minmax(0,1fr) 250px">
575
+ <div class="o-col" style="gap:14px">
576
+ @if (step() === 1) {
577
+ <div class="o-g2">
578
+ @for (e of engines(); track e.id) {
579
+ <button type="button" class="o-opt" [class.o-on]="engine()?.id === e.id" [class.o-dis]="!e.available" (click)="pickEngine(e)" [attr.data-testid]="'svc-engine-' + e.id">
580
+ <span class="o-rad" [class.o-on]="engine()?.id === e.id"></span>
581
+ <div class="o-col" style="gap:3px;flex:1"><span class="o-flex"><b>{{ e.name }}</b>@if (e.tag) {<b class="o-ph-tag o-ga">{{ e.tag }}</b>}</span><span class="o-faint o-sm o-lh">{{ e.description }}</span></div>
582
+ </button>
583
+ }
584
+ </div>
585
+ <div class="o-fld">
586
+ <label>Version</label>
587
+ <div class="o-seg" style="align-self:flex-start">
588
+ @for (v of engine()?.versions ?? []; track v.id) {<button type="button" [class.o-on]="version() === v.id" (click)="version.set(v.id)">{{ v.label }}</button>}
589
+ </div>
590
+ <span class="o-hint">Minor patches apply in your maintenance window with 7 days' notice.</span>
591
+ </div>
592
+ }
593
+ @if (step() === 2) {
594
+ <div class="o-card"><div class="o-tbl">
595
+ <div class="o-thead"><div class="o-tr"><div class="o-th"></div><div class="o-th">Plan</div><div class="o-th">vCPU</div><div class="o-th">RAM</div><div class="o-th">Disk</div><div class="o-th o-num">Monthly</div></div></div>
596
+ <div class="o-tbody">
597
+ @for (z of plans(); track z.id) {
598
+ <div class="o-tr" [class.o-click]="allowed(z)" [style.opacity]="allowed(z) ? 1 : 0.5" (click)="pickPlan(z)" [attr.data-testid]="'svc-plan-' + z.id" [attr.title]="allowed(z) ? null : quota()?.plan + ' includes ' + quota()?.onlyPlan + ' only'">
599
+ <div class="o-td"><span class="o-rad" [class.o-on]="plan()?.id === z.id"></span></div>
600
+ <div class="o-td o-mono o-b5">{{ z.id }}</div><div class="o-td">{{ z.cpu }} vCPU</div><div class="o-td">{{ ram(z) }}</div><div class="o-td">{{ z.diskGb }} GB</div>
601
+ <div class="o-td o-num o-mono">{{ price(z.priceCents) }}/mo</div>
602
+ </div>
603
+ }
604
+ </div>
605
+ </div></div>
606
+ @if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
607
+ <div class="o-g2">
608
+ <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>
610
+ </div>
611
+ }
612
+ @if (step() === 3) {
613
+ <div class="o-card o-cb o-form">
614
+ <div class="o-fld">
615
+ <label for="svc-name">Name</label>
616
+ <input id="svc-name" class="o-in o-mono" [ngModel]="name()" (ngModelChange)="name.set($event); nameTouched.set(true)" data-testid="svc-name" autocomplete="off" />
617
+ @if (nameError()) {<span class="o-hint" style="color:var(--o-bad-tx)">{{ nameError() }}</span>} @else {<span class="o-hint">Internal host: <b class="o-mono">{{ hostName() }}</b></span>}
618
+ </div>
619
+ <div class="o-g2">
620
+ <div class="o-fld"><label>Project</label><select class="o-in" disabled aria-label="Project"><option>{{ host.project()?.key }} · {{ host.project()?.name }}</option></select></div>
621
+ <div class="o-fld"><label for="svc-env">Environment</label>
622
+ <select id="svc-env" class="o-in" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-env">
623
+ @for (e of envs(); track e) {<option [value]="e">{{ e }}</option>}
624
+ </select>
625
+ </div>
626
+ </div>
627
+ <div class="o-fld"><label for="svc-attach">Attach after create</label>
628
+ @if (apps().length) {
629
+ <select id="svc-attach" class="o-in" [ngModel]="attach()" (ngModelChange)="attach.set($event)" data-testid="svc-attach">
630
+ <option value="">Don't attach yet</option>
631
+ @for (a of apps(); track a.name) {<option [value]="a.name">{{ a.name }}</option>}
632
+ </select>
633
+ } @else {
634
+ <input id="svc-attach" class="o-in o-mono" placeholder="app name (optional)" [ngModel]="attach()" (ngModelChange)="attach.set($event)" data-testid="svc-attach" />
635
+ }
636
+ <span class="o-hint">Injects {{ engine()?.envVar }} into the app and redeploys it.</span>
637
+ </div>
638
+ <div class="o-g2">
639
+ <div class="o-fld"><label for="svc-hour">Daily backup at</label>
640
+ <select id="svc-hour" class="o-in" [ngModel]="hour()" (ngModelChange)="hour.set(+$event)">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select>
641
+ </div>
642
+ <div class="o-fld"><label for="svc-keep">Retention</label>
643
+ <select id="svc-keep" class="o-in" [ngModel]="keep()" (ngModelChange)="keep.set(+$event)">@for (d of keeps; track d) {<option [ngValue]="d">{{ d }} days</option>}</select>
644
+ </div>
645
+ </div>
646
+ </div>
647
+ }
648
+ <div class="o-flex">
649
+ @if (step() > 1) {<button type="button" class="o-btn" (click)="go(step() - 1)">Back</button>}
650
+ <button type="button" class="o-btn o-dark o-r" (click)="next()" [disabled]="busy() || !canNext()" data-testid="svc-next">{{ step() < 3 ? 'Continue' : 'Create ' + name() }}</button>
651
+ </div>
652
+ </div>
653
+ <div class="o-card o-cb o-col" style="gap:10px;position:sticky;top:0;align-self:start">
654
+ <b>Summary</b>
655
+ <div class="o-kv" style="grid-template-columns:70px 1fr;font-size:12px;row-gap:6px">
656
+ <span class="o-k">Engine</span><span>{{ engine()?.name }} {{ versionLabel() }}</span>
657
+ <span class="o-k">Plan</span><span class="o-mono">{{ plan()?.id }}</span>
658
+ <span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
659
+ <span class="o-k">Region</span><span>{{ region() }}</span>
660
+ <span class="o-k">Network</span><span>Private · TLS</span>
661
+ <span class="o-k">Backups</span><span>Daily{{ engine()?.pitr ? ' + PITR' : ' RDB snapshot' }}</span>
662
+ </div>
663
+ <div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md">{{ plan() ? price(plan()!.priceCents) + '/mo' : '' }}</b></div>
664
+ <span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
665
+ </div>
666
+ </div>
667
+ </div>
668
+ `, 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 }); }
669
+ }
670
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicesNewPage, decorators: [{
671
+ type: Component,
672
+ args: [{
673
+ selector: 'o-services-new',
674
+ imports: [FormsModule],
675
+ changeDetection: ChangeDetectionStrategy.OnPush,
676
+ template: `
677
+ <div class="o-page o-narrow">
678
+ <div class="o-ph"><div class="o-col"><span class="o-eyebrow">{{ host.project()?.key }} · ready in under 5 minutes</span><h1 class="o-t">Create a service</h1></div></div>
679
+ <div class="o-flex" style="gap:6px">
680
+ @for (s of steps; track s.n; let i = $index) {
681
+ <button type="button" class="o-flex" style="border:none;background:none;cursor:pointer;gap:8px;padding:0" (click)="go(s.n)" [attr.aria-current]="step() === s.n ? 'step' : null">
682
+ <span class="o-av" [style.background]="s.n <= step() ? 'var(--o-tx)' : 'var(--o-line-soft)'" [style.color]="s.n <= step() ? 'var(--o-acc-fg)' : 'var(--o-mut)'">{{ s.n < step() ? '✓' : s.n }}</span>
683
+ <span class="o-sm" [style.color]="s.n === step() ? 'var(--o-tx)' : 'var(--o-mut)'">{{ s.label }}</span>
684
+ </button>
685
+ @if (i < steps.length - 1) {<span style="flex:1;border-top:1px solid var(--o-line);min-width:30px"></span>}
686
+ }
687
+ </div>
688
+ @if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-new-error">{{ error() }}</div>}
689
+ <div class="o-split" style="grid-template-columns:minmax(0,1fr) 250px">
690
+ <div class="o-col" style="gap:14px">
691
+ @if (step() === 1) {
692
+ <div class="o-g2">
693
+ @for (e of engines(); track e.id) {
694
+ <button type="button" class="o-opt" [class.o-on]="engine()?.id === e.id" [class.o-dis]="!e.available" (click)="pickEngine(e)" [attr.data-testid]="'svc-engine-' + e.id">
695
+ <span class="o-rad" [class.o-on]="engine()?.id === e.id"></span>
696
+ <div class="o-col" style="gap:3px;flex:1"><span class="o-flex"><b>{{ e.name }}</b>@if (e.tag) {<b class="o-ph-tag o-ga">{{ e.tag }}</b>}</span><span class="o-faint o-sm o-lh">{{ e.description }}</span></div>
697
+ </button>
698
+ }
699
+ </div>
700
+ <div class="o-fld">
701
+ <label>Version</label>
702
+ <div class="o-seg" style="align-self:flex-start">
703
+ @for (v of engine()?.versions ?? []; track v.id) {<button type="button" [class.o-on]="version() === v.id" (click)="version.set(v.id)">{{ v.label }}</button>}
704
+ </div>
705
+ <span class="o-hint">Minor patches apply in your maintenance window with 7 days' notice.</span>
706
+ </div>
707
+ }
708
+ @if (step() === 2) {
709
+ <div class="o-card"><div class="o-tbl">
710
+ <div class="o-thead"><div class="o-tr"><div class="o-th"></div><div class="o-th">Plan</div><div class="o-th">vCPU</div><div class="o-th">RAM</div><div class="o-th">Disk</div><div class="o-th o-num">Monthly</div></div></div>
711
+ <div class="o-tbody">
712
+ @for (z of plans(); track z.id) {
713
+ <div class="o-tr" [class.o-click]="allowed(z)" [style.opacity]="allowed(z) ? 1 : 0.5" (click)="pickPlan(z)" [attr.data-testid]="'svc-plan-' + z.id" [attr.title]="allowed(z) ? null : quota()?.plan + ' includes ' + quota()?.onlyPlan + ' only'">
714
+ <div class="o-td"><span class="o-rad" [class.o-on]="plan()?.id === z.id"></span></div>
715
+ <div class="o-td o-mono o-b5">{{ z.id }}</div><div class="o-td">{{ z.cpu }} vCPU</div><div class="o-td">{{ ram(z) }}</div><div class="o-td">{{ z.diskGb }} GB</div>
716
+ <div class="o-td o-num o-mono">{{ price(z.priceCents) }}/mo</div>
717
+ </div>
718
+ }
719
+ </div>
720
+ </div></div>
721
+ @if (quota()?.onlyPlan) {<span class="o-hint">The {{ quota()?.plan }} plan includes one {{ quota()?.onlyPlan }}. Upgrade in Billing for larger plans.</span>}
722
+ <div class="o-g2">
723
+ <div class="o-fld"><label>Region</label><input class="o-in" [value]="region()" readonly aria-label="Region" /></div>
724
+ <div class="o-fld"><label>High availability <b class="o-ph-tag o-ga">GA</b></label><div class="o-flex"><button type="button" class="o-tog" disabled aria-label="High availability (GA)"></button><span class="o-sm">Standby node with automatic failover</span></div></div>
725
+ </div>
726
+ }
727
+ @if (step() === 3) {
728
+ <div class="o-card o-cb o-form">
729
+ <div class="o-fld">
730
+ <label for="svc-name">Name</label>
731
+ <input id="svc-name" class="o-in o-mono" [ngModel]="name()" (ngModelChange)="name.set($event); nameTouched.set(true)" data-testid="svc-name" autocomplete="off" />
732
+ @if (nameError()) {<span class="o-hint" style="color:var(--o-bad-tx)">{{ nameError() }}</span>} @else {<span class="o-hint">Internal host: <b class="o-mono">{{ hostName() }}</b></span>}
733
+ </div>
734
+ <div class="o-g2">
735
+ <div class="o-fld"><label>Project</label><select class="o-in" disabled aria-label="Project"><option>{{ host.project()?.key }} · {{ host.project()?.name }}</option></select></div>
736
+ <div class="o-fld"><label for="svc-env">Environment</label>
737
+ <select id="svc-env" class="o-in" [ngModel]="env()" (ngModelChange)="env.set($event)" data-testid="svc-env">
738
+ @for (e of envs(); track e) {<option [value]="e">{{ e }}</option>}
739
+ </select>
740
+ </div>
741
+ </div>
742
+ <div class="o-fld"><label for="svc-attach">Attach after create</label>
743
+ @if (apps().length) {
744
+ <select id="svc-attach" class="o-in" [ngModel]="attach()" (ngModelChange)="attach.set($event)" data-testid="svc-attach">
745
+ <option value="">Don't attach yet</option>
746
+ @for (a of apps(); track a.name) {<option [value]="a.name">{{ a.name }}</option>}
747
+ </select>
748
+ } @else {
749
+ <input id="svc-attach" class="o-in o-mono" placeholder="app name (optional)" [ngModel]="attach()" (ngModelChange)="attach.set($event)" data-testid="svc-attach" />
750
+ }
751
+ <span class="o-hint">Injects {{ engine()?.envVar }} into the app and redeploys it.</span>
752
+ </div>
753
+ <div class="o-g2">
754
+ <div class="o-fld"><label for="svc-hour">Daily backup at</label>
755
+ <select id="svc-hour" class="o-in" [ngModel]="hour()" (ngModelChange)="hour.set(+$event)">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select>
756
+ </div>
757
+ <div class="o-fld"><label for="svc-keep">Retention</label>
758
+ <select id="svc-keep" class="o-in" [ngModel]="keep()" (ngModelChange)="keep.set(+$event)">@for (d of keeps; track d) {<option [ngValue]="d">{{ d }} days</option>}</select>
759
+ </div>
760
+ </div>
761
+ </div>
762
+ }
763
+ <div class="o-flex">
764
+ @if (step() > 1) {<button type="button" class="o-btn" (click)="go(step() - 1)">Back</button>}
765
+ <button type="button" class="o-btn o-dark o-r" (click)="next()" [disabled]="busy() || !canNext()" data-testid="svc-next">{{ step() < 3 ? 'Continue' : 'Create ' + name() }}</button>
766
+ </div>
767
+ </div>
768
+ <div class="o-card o-cb o-col" style="gap:10px;position:sticky;top:0;align-self:start">
769
+ <b>Summary</b>
770
+ <div class="o-kv" style="grid-template-columns:70px 1fr;font-size:12px;row-gap:6px">
771
+ <span class="o-k">Engine</span><span>{{ engine()?.name }} {{ versionLabel() }}</span>
772
+ <span class="o-k">Plan</span><span class="o-mono">{{ plan()?.id }}</span>
773
+ <span class="o-k">Size</span><span>{{ plan() ? plan()!.cpu + ' vCPU · ' + ram(plan()!) + ' · ' + plan()!.diskGb + ' GB' : '' }}</span>
774
+ <span class="o-k">Region</span><span>{{ region() }}</span>
775
+ <span class="o-k">Network</span><span>Private · TLS</span>
776
+ <span class="o-k">Backups</span><span>Daily{{ engine()?.pitr ? ' + PITR' : ' RDB snapshot' }}</span>
777
+ </div>
778
+ <div class="o-flex" style="border-top:1px solid var(--o-line-soft);padding-top:10px"><span class="o-mut">Monthly</span><b class="o-r o-mono o-md">{{ plan() ? price(plan()!.priceCents) + '/mo' : '' }}</b></div>
779
+ <span class="o-hint">Billed by instance hour. Free plan: one {{ catalog()?.freePlan || 'db-s-1' }}, hard limit.</span>
780
+ </div>
781
+ </div>
782
+ </div>
783
+ `,
784
+ }]
785
+ }], ctorParameters: () => [] });
786
+
787
+ const COLOUR = { ERROR: 'var(--o-bad-solid)', FATAL: 'var(--o-bad-solid)', PANIC: 'var(--o-bad-solid)', WARNING: 'var(--o-run)' };
788
+ // The instance page's Logs tab (prototype "svc · logs"): the engine's own log from its pods —
789
+ // all, errors, the slow log, connections — newest first, refreshed every 10 s.
790
+ class ServiceLogsTab {
791
+ constructor() {
792
+ this.api = inject(ServicesApi);
793
+ this.projectKey = input('', ...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
794
+ this.id = input('', ...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
795
+ this.service = input('', ...(ngDevMode ? [{ debugName: "service" }] : /* istanbul ignore next */ []));
796
+ this.environment = input('', ...(ngDevMode ? [{ debugName: "environment" }] : /* istanbul ignore next */ []));
797
+ this.debugLogs = input([], ...(ngDevMode ? [{ debugName: "debugLogs" }] : /* istanbul ignore next */ []));
798
+ this.kinds = [
799
+ { id: 'all', label: 'All' }, { id: 'errors', label: 'Errors' }, { id: 'slow', label: 'Slow log' }, { id: 'connections', label: 'Connections' },
800
+ ];
801
+ this.kind = signal('all', ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
802
+ this.lines = signal([], ...(ngDevMode ? [{ debugName: "lines" }] : /* istanbul ignore next */ []));
803
+ this.note = signal('', ...(ngDevMode ? [{ debugName: "note" }] : /* istanbul ignore next */ []));
804
+ this.loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
805
+ this.ts = hhmmss;
806
+ this.colour = (l) => COLOUR[l] ?? '#8fa3c0';
807
+ effect(() => {
808
+ const [p, id, k] = [this.projectKey(), this.id(), this.kind()];
809
+ if (p && id)
810
+ untracked(() => this.load(p, id, k));
811
+ });
812
+ inject(DestroyRef).onDestroy(() => clearTimeout(this.timer));
813
+ }
814
+ pick(k) {
815
+ this.loaded.set(false);
816
+ this.kind.set(k);
817
+ }
818
+ load(p, id, k) {
819
+ clearTimeout(this.timer);
820
+ this.api.logs(p, id, k).subscribe({
821
+ next: (l) => {
822
+ if (k !== this.kind())
823
+ return;
824
+ this.lines.set(l.lines ?? []);
825
+ this.note.set(l.message ?? '');
826
+ this.loaded.set(true);
827
+ this.timer = setTimeout(() => this.load(p, id, k), 10_000);
828
+ },
829
+ error: (e) => {
830
+ this.loaded.set(true);
831
+ this.note.set(apiMessage(e, 'The engine log could not be read.'));
832
+ },
833
+ });
834
+ }
835
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceLogsTab, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
836
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceLogsTab, isStandalone: true, selector: "o-svc-logs", inputs: { projectKey: { classPropertyName: "projectKey", publicName: "projectKey", isSignal: true, isRequired: false, transformFunction: null }, id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, service: { classPropertyName: "service", publicName: "service", isSignal: true, isRequired: false, transformFunction: null }, environment: { classPropertyName: "environment", publicName: "environment", isSignal: true, isRequired: false, transformFunction: null }, debugLogs: { classPropertyName: "debugLogs", publicName: "debugLogs", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
837
+ <div class="o-flex">
838
+ <div class="o-seg" role="tablist" aria-label="Log kind">
839
+ @for (k of kinds; track k.id) {<button type="button" role="tab" [class.o-on]="kind() === k.id" [attr.aria-selected]="kind() === k.id" (click)="pick(k.id)" [attr.data-testid]="'svc-log-' + k.id">{{ k.label }}</button>}
840
+ </div>
841
+ <a class="o-btn o-sm o-r" [routerLink]="debugLogs()" [queryParams]="{ service: service() }">Open in Debug</a>
842
+ </div>
843
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
844
+ <div class="o-term" style="min-height:260px;max-height:520px;overflow:auto" data-testid="svc-logs">
845
+ @for (l of lines(); track $index) {
846
+ <div class="o-flex" style="gap:12px;white-space:nowrap"><span class="o-c">{{ ts(l.time) }}</span><span style="width:60px" [style.color]="colour(l.level)">{{ l.level }}</span><span class="o-ell" [attr.title]="l.message">{{ l.message }}</span></div>
847
+ } @empty {<div class="o-c">{{ loaded() ? 'No lines of this kind in the engine log.' : 'Loading…' }}</div>}
848
+ </div>
849
+ <span class="o-faint o-sm">Engine logs are kept 7 days and labelled service={{ service() }}, project={{ projectKey() }}, environment={{ environment() || 'shared' }}.</span>
850
+ `, isInline: true, dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
851
+ }
852
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceLogsTab, decorators: [{
853
+ type: Component,
854
+ args: [{
855
+ selector: 'o-svc-logs',
856
+ imports: [RouterLink],
857
+ changeDetection: ChangeDetectionStrategy.OnPush,
858
+ template: `
859
+ <div class="o-flex">
860
+ <div class="o-seg" role="tablist" aria-label="Log kind">
861
+ @for (k of kinds; track k.id) {<button type="button" role="tab" [class.o-on]="kind() === k.id" [attr.aria-selected]="kind() === k.id" (click)="pick(k.id)" [attr.data-testid]="'svc-log-' + k.id">{{ k.label }}</button>}
862
+ </div>
863
+ <a class="o-btn o-sm o-r" [routerLink]="debugLogs()" [queryParams]="{ service: service() }">Open in Debug</a>
864
+ </div>
865
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
866
+ <div class="o-term" style="min-height:260px;max-height:520px;overflow:auto" data-testid="svc-logs">
867
+ @for (l of lines(); track $index) {
868
+ <div class="o-flex" style="gap:12px;white-space:nowrap"><span class="o-c">{{ ts(l.time) }}</span><span style="width:60px" [style.color]="colour(l.level)">{{ l.level }}</span><span class="o-ell" [attr.title]="l.message">{{ l.message }}</span></div>
869
+ } @empty {<div class="o-c">{{ loaded() ? 'No lines of this kind in the engine log.' : 'Loading…' }}</div>}
870
+ </div>
871
+ <span class="o-faint o-sm">Engine logs are kept 7 days and labelled service={{ service() }}, project={{ projectKey() }}, environment={{ environment() || 'shared' }}.</span>
872
+ `,
873
+ }]
874
+ }], ctorParameters: () => [], propDecorators: { projectKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "projectKey", required: false }] }], id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }], service: [{ type: i0.Input, args: [{ isSignal: true, alias: "service", required: false }] }], environment: [{ type: i0.Input, args: [{ isSignal: true, alias: "environment", required: false }] }], debugLogs: [{ type: i0.Input, args: [{ isSignal: true, alias: "debugLogs", required: false }] }] } });
875
+
876
+ const RANGES = [{ id: '1h', label: '1 h' }, { id: '6h', label: '6 h' }, { id: '24h', label: '24 h' }, { id: '7d', label: '7 d' }];
877
+ /**
878
+ * The stored series behind each chart: the exporter metrics debug's engine dashboards read (PLAN,
879
+ * "Service metrics"), and the Collector's pod memory. Charts without one draw the live readings.
880
+ */
881
+ const HISTORY = {
882
+ postgres: {
883
+ memory: ['k8s_pod_memory_working_set_bytes', 'max'], connections: ['cnpg_backends_total', 'sum'], transactions: ['cnpg_pg_stat_database_xact_commit', 'rate'],
884
+ rollbacks: ['cnpg_pg_stat_database_xact_rollback', 'rate'], database_size: ['cnpg_pg_database_size_bytes', 'sum'], replication_lag: ['cnpg_pg_replication_lag', 'max'],
885
+ },
886
+ mysql: {
887
+ memory: ['k8s_pod_memory_working_set_bytes', 'max'], connections: ['mysql_global_status_threads_connected', 'sum'], queries: ['mysql_global_status_queries', 'rate'],
888
+ slow_queries: ['mysql_global_status_slow_queries', 'rate'], replication_lag: ['mysql_slave_status_seconds_behind_master', 'max'],
889
+ },
890
+ valkey: {
891
+ memory: ['k8s_pod_memory_working_set_bytes', 'max'], connections: ['redis_connected_clients', 'sum'], commands: ['redis_commands_processed_total', 'rate'],
892
+ memory_used: ['redis_memory_used_bytes', 'max'], keys: ['redis_db_keys', 'sum'], evictions: ['redis_evicted_keys_total', 'rate'],
893
+ },
894
+ };
895
+ // The instance page's Metrics tab (prototype "svc · metrics"): the figures the cell reports now,
896
+ // drawn over 1 h to 7 d from the series the Collector stores (the auto-created dashboard's data);
897
+ // a cell without telemetry stores shows the readings taken while the page is open.
898
+ class ServiceMetricsTab {
899
+ constructor() {
900
+ this.host = inject(ServicesHost);
901
+ this.readings = input([], ...(ngDevMode ? [{ debugName: "readings" }] : /* istanbul ignore next */ []));
902
+ this.slowLines = input([], ...(ngDevMode ? [{ debugName: "slowLines" }] : /* istanbul ignore next */ []));
903
+ this.engine = input('', ...(ngDevMode ? [{ debugName: "engine" }] : /* istanbul ignore next */ []));
904
+ this.projectKey = input('', ...(ngDevMode ? [{ debugName: "projectKey" }] : /* istanbul ignore next */ []));
905
+ this.service = input('', ...(ngDevMode ? [{ debugName: "service" }] : /* istanbul ignore next */ []));
906
+ this.dashboard = input([], ...(ngDevMode ? [{ debugName: "dashboard" }] : /* istanbul ignore next */ []));
907
+ this.ranges = RANGES;
908
+ this.range = signal('1h', ...(ngDevMode ? [{ debugName: "range" }] : /* istanbul ignore next */ []));
909
+ /** null: not known yet; false: the cell keeps no telemetry. */
910
+ this.stored = signal(null, ...(ngDevMode ? [{ debugName: "stored" }] : /* istanbul ignore next */ []));
911
+ this.series = signal({}, ...(ngDevMode ? [{ debugName: "series" }] : /* istanbul ignore next */ []));
912
+ this.latest = computed(() => this.readings().at(-1) ?? null, ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
913
+ this.draw = (v) => (v.length >= 2 ? spark(v) : null);
914
+ this.charts = computed(() => {
915
+ const rs = this.readings();
916
+ const m = rs.at(-1);
917
+ const stored = this.range() !== 'live' ? this.series() : {};
918
+ const pick = (key, live) => (stored[key]?.length ? stored[key] : live);
919
+ const gauge = (key, title, show, warnAt = 85) => ({
920
+ key, title, value: show(m?.[key]),
921
+ points: pick(key, rs.map((r) => r[key]?.used ?? NaN).filter((x) => !Number.isNaN(x))),
922
+ warn: (m?.[key]?.percent ?? 0) >= warnAt,
923
+ });
924
+ const out = [
925
+ gauge('cpu', 'CPU', (g) => (g?.percent != null ? `${pct(g.percent)} of ${num(g.limit)} vCPU` : '–')),
926
+ gauge('memory', 'Memory', (g) => (g?.used != null ? `${bytes(g.used)} · ${pct(g.percent)}` : '–')),
927
+ gauge('disk', 'Disk used', (g) => (g?.used != null ? `${bytes(g.used)} of ${bytes(g.limit)}` : `– of ${bytes(g?.limit)}`)),
928
+ gauge('connections', 'Connections', (g) => ratio(g)),
929
+ ];
930
+ for (const f of m?.figures ?? []) {
931
+ const live = rs.map((r) => r.figures.find((x) => x.key === f.key)).filter((x) => !!x).map((x) => (x.counter ? x.rate : x.value)).filter((x) => x != null);
932
+ out.push({ key: f.key, title: f.counter ? `${f.label} / s` : f.label, value: show(f), points: pick(f.key, live) });
933
+ }
934
+ return out;
935
+ }, ...(ngDevMode ? [{ debugName: "charts" }] : /* istanbul ignore next */ []));
936
+ this.slow = computed(() => this.slowLines()
937
+ .slice(0, 8)
938
+ .map((l) => {
939
+ const m = /^duration: ([\d.]+) ms\s+statement:\s*(.*)$/s.exec(l.message);
940
+ return { statement: m ? m[2] : l.message, duration: m ? `${num(Number(m[1]), 1)} ms` : '', when: when(l.time) || hhmmss(l.time) };
941
+ }), ...(ngDevMode ? [{ debugName: "slow" }] : /* istanbul ignore next */ []));
942
+ effect(() => {
943
+ const [p, s, e, r] = [this.projectKey(), this.service(), this.engine(), this.range()];
944
+ if (p && s && e && r !== 'live')
945
+ untracked(() => this.load(p, s, e, r));
946
+ });
947
+ }
948
+ load(p, s, e, r) {
949
+ const wanted = HISTORY[e] ?? {};
950
+ const keys = Object.keys(wanted);
951
+ if (!keys.length)
952
+ return;
953
+ forkJoin(keys.map((k) => this.host.history(p, s, wanted[k][0], wanted[k][1], r).pipe(catchError(() => of(null))))).subscribe((all) => {
954
+ if (r !== this.range())
955
+ return;
956
+ if (all.every((x) => x === null)) {
957
+ this.stored.set(false);
958
+ this.range.set('live');
959
+ return;
960
+ }
961
+ this.stored.set(true);
962
+ const out = {};
963
+ keys.forEach((k, i) => (out[k] = (all[i] ?? []).map(([, v]) => v).filter((v) => Number.isFinite(v))));
964
+ this.series.set(out);
965
+ });
966
+ }
967
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceMetricsTab, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
968
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServiceMetricsTab, isStandalone: true, selector: "o-svc-metrics", inputs: { readings: { classPropertyName: "readings", publicName: "readings", isSignal: true, isRequired: false, transformFunction: null }, slowLines: { classPropertyName: "slowLines", publicName: "slowLines", isSignal: true, isRequired: false, transformFunction: null }, engine: { classPropertyName: "engine", publicName: "engine", isSignal: true, isRequired: false, transformFunction: null }, projectKey: { classPropertyName: "projectKey", publicName: "projectKey", isSignal: true, isRequired: false, transformFunction: null }, service: { classPropertyName: "service", publicName: "service", isSignal: true, isRequired: false, transformFunction: null }, dashboard: { classPropertyName: "dashboard", publicName: "dashboard", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
969
+ <div class="o-flex">
970
+ <div class="o-seg">
971
+ @if (stored() === false) {<button type="button" class="o-on">Live</button>}
972
+ @else {@for (r of ranges; track r.id) {<button type="button" [class.o-on]="range() === r.id" (click)="range.set(r.id)" [attr.data-testid]="'svc-range-' + r.id">{{ r.label }}</button>}}
973
+ </div>
974
+ @if (stored() === false) {<span class="o-faint o-sm">readings every 10 s since you opened the page · this cell keeps no telemetry history</span>}
975
+ <a class="o-r o-faint o-sm" [routerLink]="dashboard()">Same data as the auto-created dashboard</a>
976
+ </div>
977
+ @if (latest(); as m) {
978
+ @if (!m.available || m.message) {<div class="o-note" role="status">{{ m.message || 'Metrics are not available yet.' }}</div>}
979
+ }
980
+ <div class="o-g3">
981
+ @for (c of charts(); track c.key) {
982
+ <div class="o-card o-cb o-col" style="gap:6px" [attr.data-testid]="'svc-chart-' + c.key">
983
+ <div class="o-flex"><b class="o-b5">{{ c.title }}</b><span class="o-r o-mono o-sm">{{ c.value }}</span></div>
984
+ <svg class="o-chart" viewBox="0 0 200 60" preserveAspectRatio="none" style="height:70px;width:100%;display:block" role="img" [attr.aria-label]="c.title + ' ' + c.value">
985
+ @if (draw(c.points); as s) {
986
+ <path [attr.d]="s.area" [attr.fill]="c.warn ? 'var(--o-bad-solid)' : 'var(--o-vio)'" fill-opacity="0.08" stroke="none" />
987
+ <path [attr.d]="s.line" fill="none" [attr.stroke]="c.warn ? 'var(--o-bad-solid)' : 'var(--o-vio)'" stroke-width="1.5" vector-effect="non-scaling-stroke" />
988
+ } @else {<line x1="0" y1="30" x2="200" y2="30" stroke="var(--o-line-soft)" stroke-dasharray="3 3" vector-effect="non-scaling-stroke" />}
989
+ </svg>
990
+ </div>
991
+ }
992
+ </div>
993
+ <div class="o-card">
994
+ <div class="o-ch"><b>Slow queries</b><span class="o-faint o-sm">the latest from the slow log · over 250 ms</span><b class="o-ph-tag o-ga">GA</b><span class="o-r o-faint o-sm">normalised, top by total time at GA</span></div>
995
+ <div class="o-tbl">
996
+ <div class="o-thead"><div class="o-tr"><div class="o-th">Query</div><div class="o-th o-num">Duration</div><div class="o-th o-num">When</div></div></div>
997
+ <div class="o-tbody">
998
+ @for (q of slow(); track $index) {
999
+ <div class="o-tr"><div class="o-td o-mono o-sm o-ell" style="max-width:520px" [attr.title]="q.statement">{{ q.statement }}</div><div class="o-td o-num o-mono">{{ q.duration }}</div><div class="o-td o-num o-mono o-faint">{{ q.when }}</div></div>
1000
+ } @empty {<div class="o-empty">{{ engine() === 'valkey' ? 'Valkey keeps its slow log in memory (SLOWLOG GET).' : 'No statement over 250 ms in the engine log.' }}</div>}
1001
+ </div>
1002
+ </div>
1003
+ </div>
1004
+ `, isInline: true, dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1005
+ }
1006
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServiceMetricsTab, decorators: [{
1007
+ type: Component,
1008
+ args: [{
1009
+ selector: 'o-svc-metrics',
1010
+ imports: [RouterLink],
1011
+ changeDetection: ChangeDetectionStrategy.OnPush,
1012
+ template: `
1013
+ <div class="o-flex">
1014
+ <div class="o-seg">
1015
+ @if (stored() === false) {<button type="button" class="o-on">Live</button>}
1016
+ @else {@for (r of ranges; track r.id) {<button type="button" [class.o-on]="range() === r.id" (click)="range.set(r.id)" [attr.data-testid]="'svc-range-' + r.id">{{ r.label }}</button>}}
1017
+ </div>
1018
+ @if (stored() === false) {<span class="o-faint o-sm">readings every 10 s since you opened the page · this cell keeps no telemetry history</span>}
1019
+ <a class="o-r o-faint o-sm" [routerLink]="dashboard()">Same data as the auto-created dashboard</a>
1020
+ </div>
1021
+ @if (latest(); as m) {
1022
+ @if (!m.available || m.message) {<div class="o-note" role="status">{{ m.message || 'Metrics are not available yet.' }}</div>}
1023
+ }
1024
+ <div class="o-g3">
1025
+ @for (c of charts(); track c.key) {
1026
+ <div class="o-card o-cb o-col" style="gap:6px" [attr.data-testid]="'svc-chart-' + c.key">
1027
+ <div class="o-flex"><b class="o-b5">{{ c.title }}</b><span class="o-r o-mono o-sm">{{ c.value }}</span></div>
1028
+ <svg class="o-chart" viewBox="0 0 200 60" preserveAspectRatio="none" style="height:70px;width:100%;display:block" role="img" [attr.aria-label]="c.title + ' ' + c.value">
1029
+ @if (draw(c.points); as s) {
1030
+ <path [attr.d]="s.area" [attr.fill]="c.warn ? 'var(--o-bad-solid)' : 'var(--o-vio)'" fill-opacity="0.08" stroke="none" />
1031
+ <path [attr.d]="s.line" fill="none" [attr.stroke]="c.warn ? 'var(--o-bad-solid)' : 'var(--o-vio)'" stroke-width="1.5" vector-effect="non-scaling-stroke" />
1032
+ } @else {<line x1="0" y1="30" x2="200" y2="30" stroke="var(--o-line-soft)" stroke-dasharray="3 3" vector-effect="non-scaling-stroke" />}
1033
+ </svg>
1034
+ </div>
1035
+ }
1036
+ </div>
1037
+ <div class="o-card">
1038
+ <div class="o-ch"><b>Slow queries</b><span class="o-faint o-sm">the latest from the slow log · over 250 ms</span><b class="o-ph-tag o-ga">GA</b><span class="o-r o-faint o-sm">normalised, top by total time at GA</span></div>
1039
+ <div class="o-tbl">
1040
+ <div class="o-thead"><div class="o-tr"><div class="o-th">Query</div><div class="o-th o-num">Duration</div><div class="o-th o-num">When</div></div></div>
1041
+ <div class="o-tbody">
1042
+ @for (q of slow(); track $index) {
1043
+ <div class="o-tr"><div class="o-td o-mono o-sm o-ell" style="max-width:520px" [attr.title]="q.statement">{{ q.statement }}</div><div class="o-td o-num o-mono">{{ q.duration }}</div><div class="o-td o-num o-mono o-faint">{{ q.when }}</div></div>
1044
+ } @empty {<div class="o-empty">{{ engine() === 'valkey' ? 'Valkey keeps its slow log in memory (SLOWLOG GET).' : 'No statement over 250 ms in the engine log.' }}</div>}
1045
+ </div>
1046
+ </div>
1047
+ </div>
1048
+ `,
1049
+ }]
1050
+ }], ctorParameters: () => [], propDecorators: { readings: [{ type: i0.Input, args: [{ isSignal: true, alias: "readings", required: false }] }], slowLines: [{ type: i0.Input, args: [{ isSignal: true, alias: "slowLines", required: false }] }], engine: [{ type: i0.Input, args: [{ isSignal: true, alias: "engine", required: false }] }], projectKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "projectKey", required: false }] }], service: [{ type: i0.Input, args: [{ isSignal: true, alias: "service", required: false }] }], dashboard: [{ type: i0.Input, args: [{ isSignal: true, alias: "dashboard", required: false }] }] } });
1051
+ function show(f) {
1052
+ if (f.counter)
1053
+ return f.rate != null ? `${num(f.rate, f.rate < 10 ? 1 : 0)}/s` : `${num(f.value)} total`;
1054
+ switch (f.unit) {
1055
+ case '%':
1056
+ return pct(f.value);
1057
+ case 'bytes':
1058
+ return bytes(f.value);
1059
+ case 's':
1060
+ return f.key === 'uptime' ? `${num(f.value / 3600, 1)} h` : `${num(f.value, 1)} s`;
1061
+ }
1062
+ return num(f.value);
1063
+ }
1064
+
1065
+ const TABS = [
1066
+ { id: 'overview', label: 'Overview' }, { id: 'users', label: 'Databases and users' }, { id: 'backups', label: 'Backups' }, { id: 'metrics', label: 'Metrics' },
1067
+ { id: 'logs', label: 'Logs' }, { id: 'console', label: 'Console' }, { id: 'settings', label: 'Settings' },
1068
+ ];
1069
+ /** Engine parameters the prototype lists (validated subset, GA). */
1070
+ const PARAMS = {
1071
+ postgres: [['max_connections', '100', 'restart'], ['work_mem', '4MB', 'live'], ['log_min_duration_statement', '250ms', 'live']],
1072
+ mysql: [['max_connections', '151', 'restart'], ['long_query_time', '0.25', 'live'], ['innodb_buffer_pool_size', 'auto', 'restart']],
1073
+ valkey: [['maxmemory-policy', 'allkeys-lru', 'live'], ['timeout', '0', 'live'], ['slowlog-log-slower-than', '10000', 'live']],
1074
+ };
1075
+ // /{org}/{project}/services/:id (prototype "services"): one instance — its connection details and
1076
+ // reveal, attached apps, databases and users, backups and point-in-time restore, metrics and engine
1077
+ // logs read from the cell, and settings (resize, networking, maintenance, delete). Console, HA and
1078
+ // engine parameters are tagged GA, as the prototype tags them.
1079
+ class ServicePage {
1080
+ constructor() {
1081
+ this.host = inject(ServicesHost);
1082
+ this.api = inject(ServicesApi);
1083
+ this.route = inject(ActivatedRoute);
1084
+ this.router = inject(Router);
1085
+ this.tabs = TABS;
1086
+ this.presets = PRESETS;
1087
+ this.days = DAYS;
1088
+ this.hours = Array.from({ length: 24 }, (_, i) => i);
1089
+ this.keeps = [7, 14, 35];
1090
+ this.graces = [{ m: 0, label: 'No grace: stop it at once' }, { m: 15, label: '15 minutes' }, { m: 60, label: '1 hour' }, { m: 1440, label: '24 hours' }];
1091
+ this.chip = stateChip;
1092
+ this.preset = presetChip;
1093
+ this.kind = backupKind;
1094
+ this.price = money;
1095
+ this.utc = utcHour;
1096
+ this.when = (s) => when(s);
1097
+ this.whenLocal = (v) => when(fromLocalInput(v));
1098
+ this.d = signal(null, ...(ngDevMode ? [{ debugName: "d" }] : /* istanbul ignore next */ []));
1099
+ this.catalog = signal(null, ...(ngDevMode ? [{ debugName: "catalog" }] : /* istanbul ignore next */ []));
1100
+ this.apps = signal([], ...(ngDevMode ? [{ debugName: "apps" }] : /* istanbul ignore next */ []));
1101
+ this.tab = signal('overview', ...(ngDevMode ? [{ debugName: "tab" }] : /* istanbul ignore next */ []));
1102
+ this.readings = signal([], ...(ngDevMode ? [{ debugName: "readings" }] : /* istanbul ignore next */ []));
1103
+ this.slowLines = signal([], ...(ngDevMode ? [{ debugName: "slowLines" }] : /* istanbul ignore next */ []));
1104
+ this.revealed = signal(null, ...(ngDevMode ? [{ debugName: "revealed" }] : /* istanbul ignore next */ []));
1105
+ this.snip = signal('uri', ...(ngDevMode ? [{ debugName: "snip" }] : /* istanbul ignore next */ []));
1106
+ this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1107
+ this.notice = signal('', ...(ngDevMode ? [{ debugName: "notice" }] : /* istanbul ignore next */ []));
1108
+ this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
1109
+ this.dialog = signal(null, ...(ngDevMode ? [{ debugName: "dialog" }] : /* istanbul ignore next */ []));
1110
+ this.attachOpen = signal(false, ...(ngDevMode ? [{ debugName: "attachOpen" }] : /* istanbul ignore next */ []));
1111
+ this.attApp = signal('', ...(ngDevMode ? [{ debugName: "attApp" }] : /* istanbul ignore next */ []));
1112
+ this.attEnv = signal('production', ...(ngDevMode ? [{ debugName: "attEnv" }] : /* istanbul ignore next */ []));
1113
+ this.attPrefix = signal('', ...(ngDevMode ? [{ debugName: "attPrefix" }] : /* istanbul ignore next */ []));
1114
+ this.addingDb = signal(false, ...(ngDevMode ? [{ debugName: "addingDb" }] : /* istanbul ignore next */ []));
1115
+ this.dbName = signal('', ...(ngDevMode ? [{ debugName: "dbName" }] : /* istanbul ignore next */ []));
1116
+ this.addingUser = signal(false, ...(ngDevMode ? [{ debugName: "addingUser" }] : /* istanbul ignore next */ []));
1117
+ this.userName = signal('', ...(ngDevMode ? [{ debugName: "userName" }] : /* istanbul ignore next */ []));
1118
+ this.userPreset = signal('read-only', ...(ngDevMode ? [{ debugName: "userPreset" }] : /* istanbul ignore next */ []));
1119
+ this.pitr = signal('', ...(ngDevMode ? [{ debugName: "pitr" }] : /* istanbul ignore next */ []));
1120
+ this.newPlan = signal('', ...(ngDevMode ? [{ debugName: "newPlan" }] : /* istanbul ignore next */ []));
1121
+ this.newDisk = signal(0, ...(ngDevMode ? [{ debugName: "newDisk" }] : /* istanbul ignore next */ []));
1122
+ this.cidr = signal('', ...(ngDevMode ? [{ debugName: "cidr" }] : /* istanbul ignore next */ []));
1123
+ this.key = computed(() => this.host.project()?.key ?? '', ...(ngDevMode ? [{ debugName: "key" }] : /* istanbul ignore next */ []));
1124
+ this.id = signal('', ...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
1125
+ this.engine = computed(() => this.catalog()?.engines.find((e) => e.id === this.d()?.engine) ?? null, ...(ngDevMode ? [{ debugName: "engine" }] : /* istanbul ignore next */ []));
1126
+ this.envVar = computed(() => this.engine()?.envVar ?? 'DATABASE_URL', ...(ngDevMode ? [{ debugName: "envVar" }] : /* istanbul ignore next */ []));
1127
+ this.plans = computed(() => this.catalog()?.plans ?? [], ...(ngDevMode ? [{ debugName: "plans" }] : /* istanbul ignore next */ []));
1128
+ this.region = computed(() => this.host.project()?.region || 'your region', ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
1129
+ this.params = computed(() => PARAMS[this.d()?.engine ?? ''] ?? [], ...(ngDevMode ? [{ debugName: "params" }] : /* istanbul ignore next */ []));
1130
+ this.conn = computed(() => this.revealed()?.connection ?? this.d()?.connection ?? null, ...(ngDevMode ? [{ debugName: "conn" }] : /* istanbul ignore next */ []));
1131
+ this.snippetKeys = computed(() => {
1132
+ const s = this.conn()?.snippets ?? {};
1133
+ return ['uri', 'psql', 'mysql', 'valkey-cli', 'node'].filter((k) => k in s);
1134
+ }, ...(ngDevMode ? [{ debugName: "snippetKeys" }] : /* istanbul ignore next */ []));
1135
+ this.backups = computed(() => [...(this.d()?.backups ?? [])].sort((a, b) => (b.startedAt ?? '9').localeCompare(a.startedAt ?? '9')), ...(ngDevMode ? [{ debugName: "backups" }] : /* istanbul ignore next */ []));
1136
+ this.attEnvs = computed(() => {
1137
+ const set = new Set();
1138
+ const env = this.d()?.environment;
1139
+ if (env)
1140
+ set.add(env);
1141
+ this.apps().find((a) => a.name === this.attApp())?.environments.forEach((e) => set.add(e));
1142
+ ['production', 'staging'].forEach((e) => set.add(e));
1143
+ return [...set];
1144
+ }, ...(ngDevMode ? [{ debugName: "attEnvs" }] : /* istanbul ignore next */ []));
1145
+ this.openRotation = computed(() => this.d()?.rotations.find((r) => !r.finishedAt) ?? null, ...(ngDevMode ? [{ debugName: "openRotation" }] : /* istanbul ignore next */ []));
1146
+ this.rotateNote = computed(() => {
1147
+ const d = this.d();
1148
+ if (!d)
1149
+ return '';
1150
+ const open = this.openRotation();
1151
+ if (open) {
1152
+ const pending = (open.apps ?? []).filter((a) => a.state !== 'succeeded' && a.state !== 'live').map((a) => a.app);
1153
+ if (open.graceUntil)
1154
+ return `Rotated ${when(open.createdAt)} · the previous password stays valid until ${when(open.graceUntil)}`;
1155
+ return `Rotating since ${when(open.createdAt)} · ${pending.length ? 'redeploying ' + pending.join(', ') : 'applying the new password'}; the previous password stays valid until every app is healthy`;
1156
+ }
1157
+ const owner = d.users?.find((u) => u.preset === 'owner') ?? d.users?.[0];
1158
+ const last = d.rotations.find((r) => r.finishedAt);
1159
+ if (last && last.state === 'kept')
1160
+ return `The last rotation kept generation ${last.fromGeneration}: ${last.message || 'an app did not become healthy'}`;
1161
+ return `Credential generation ${owner?.generation ?? 1}${last ? ' · rotated ' + when(last.finishedAt) : ''} · rotation redeploys every attached app`;
1162
+ }, ...(ngDevMode ? [{ debugName: "rotateNote" }] : /* istanbul ignore next */ []));
1163
+ this.windowLine = computed(() => {
1164
+ const d = this.d();
1165
+ if (!d)
1166
+ return '';
1167
+ if (!d.pitr)
1168
+ return 'Restores from a daily RDB snapshot into a new instance.';
1169
+ if (!d.window?.from)
1170
+ return 'Point-in-time recovery starts with the first backup.';
1171
+ return `Point-in-time recovery to any second since ${when(d.window.from)}${d.window.to ? ' until ' + when(d.window.to) : ''}.`;
1172
+ }, ...(ngDevMode ? [{ debugName: "windowLine" }] : /* istanbul ignore next */ []));
1173
+ this.stats = computed(() => {
1174
+ const m = this.readings().at(-1);
1175
+ const d = this.d();
1176
+ const off = !m?.available;
1177
+ const fig = (k) => m?.figures.find((f) => f.key === k);
1178
+ const disk = m?.disk;
1179
+ const out = [
1180
+ { label: 'Connections', value: off ? '–' : ratio(m?.connections), sub: off ? (m?.message ?? 'reading…') : `${this.d()?.engineName} limit` },
1181
+ { label: 'CPU', value: off ? '–' : pct(m?.cpu?.percent), sub: `${num(m?.cpu?.limit ?? Number(this.plans().find((p) => p.id === d?.plan)?.cpu ?? 0))} vCPU` },
1182
+ { label: 'Disk', value: disk?.percent != null ? pct(disk.percent) : '–', sub: disk?.used != null ? `${bytes(disk.used)} of ${bytes(disk.limit)}${disk.source === 'engine' ? ' (data)' : ''}` : `of ${d?.diskGb ?? 0} GB` },
1183
+ ];
1184
+ if (d?.engine === 'postgres') {
1185
+ const tx = fig('transactions');
1186
+ out.push({ label: 'Cache hit', value: fig('cache_hit') ? pct(fig('cache_hit').value) : '–', sub: tx?.rate != null ? `${num(tx.rate, 1)} tx/s` : 'transactions ' + num(tx?.value) });
1187
+ }
1188
+ else if (d?.engine === 'mysql') {
1189
+ const q = fig('queries');
1190
+ out.push({ label: 'Queries / s', value: q?.rate != null ? num(q.rate, 1) : '–', sub: `slow ${num(fig('slow_queries')?.value)} since start` });
1191
+ }
1192
+ else {
1193
+ out.push({ label: 'Hit ratio', value: fig('hit_ratio') ? pct(fig('hit_ratio').value) : '–', sub: `${num(fig('keys')?.value)} keys · ${bytes(fig('memory_used')?.value)}` });
1194
+ }
1195
+ return out;
1196
+ }, ...(ngDevMode ? [{ debugName: "stats" }] : /* istanbul ignore next */ []));
1197
+ this.loadedFor = '';
1198
+ const params = this.route.paramMap.subscribe((pm) => this.id.set(pm.get('id') ?? ''));
1199
+ const query = this.route.queryParamMap.subscribe((q) => {
1200
+ const t = q.get('tab');
1201
+ this.tab.set(t && TABS.some((x) => x.id === t) ? t : 'overview');
1202
+ });
1203
+ effect(() => {
1204
+ const [k, id] = [this.key(), this.id()];
1205
+ if (k && id && k + '/' + id !== this.loadedFor) {
1206
+ this.loadedFor = k + '/' + id;
1207
+ untracked(() => this.first(k, id));
1208
+ }
1209
+ });
1210
+ // Metrics while Overview or Metrics shows; the slow log for the Metrics tab.
1211
+ effect(() => {
1212
+ const [t, d] = [this.tab(), this.d()?.id];
1213
+ untracked(() => {
1214
+ clearTimeout(this.metricsTimer);
1215
+ if (d && (t === 'overview' || t === 'metrics'))
1216
+ this.pollMetrics();
1217
+ if (d && t === 'metrics')
1218
+ this.api.logs(this.key(), d, 'slow', 20).pipe(catchError(() => of(null))).subscribe((l) => this.slowLines.set(l?.lines ?? []));
1219
+ });
1220
+ });
1221
+ inject(DestroyRef).onDestroy(() => {
1222
+ params.unsubscribe();
1223
+ query.unsubscribe();
1224
+ clearTimeout(this.loadTimer);
1225
+ clearTimeout(this.metricsTimer);
1226
+ });
1227
+ }
1228
+ first(k, id) {
1229
+ this.d.set(null);
1230
+ this.readings.set([]);
1231
+ this.revealed.set(null);
1232
+ forkJoin({
1233
+ catalog: this.catalog() ? of(this.catalog()) : this.api.catalog().pipe(catchError(() => of(null))),
1234
+ apps: this.host.apps(k).pipe(catchError(() => of([]))),
1235
+ }).subscribe(({ catalog, apps }) => {
1236
+ this.catalog.set(catalog);
1237
+ this.apps.set(apps);
1238
+ if (apps.length && !this.attApp())
1239
+ this.attApp.set(apps[0].name);
1240
+ });
1241
+ this.load();
1242
+ }
1243
+ load() {
1244
+ clearTimeout(this.loadTimer);
1245
+ const [k, id] = [this.key(), this.id()];
1246
+ this.api.get(k, id).subscribe({
1247
+ next: (d) => {
1248
+ if (k !== this.key() || id !== this.id())
1249
+ return;
1250
+ const firstLoad = !this.d();
1251
+ this.d.set(d);
1252
+ this.error.set('');
1253
+ if (firstLoad) {
1254
+ this.newPlan.set(d.plan);
1255
+ this.newDisk.set(d.diskGb);
1256
+ if (d.environment)
1257
+ this.attEnv.set(d.environment);
1258
+ if (d.window?.from && !this.pitr())
1259
+ this.pitr.set(toLocalInput(new Date().toISOString()));
1260
+ }
1261
+ const settling = moving(d.state) || !!this.openRotation() || d.attachments.some((a) => a.state === 'pending') || (d.backups ?? []).some((b) => b.phase === 'running' || b.phase === 'pending');
1262
+ this.loadTimer = setTimeout(() => this.load(), settling ? 4000 : 30_000);
1263
+ },
1264
+ error: (e) => {
1265
+ this.error.set(e?.status === 404 ? 'No such service in this project.' : apiMessage(e, 'The service could not be loaded.'));
1266
+ },
1267
+ });
1268
+ }
1269
+ pollMetrics() {
1270
+ const d = this.d();
1271
+ if (!d)
1272
+ return;
1273
+ this.api.metrics(this.key(), d.id).subscribe({
1274
+ next: (m) => {
1275
+ this.readings.update((rs) => [...rs, m].slice(-90));
1276
+ this.metricsTimer = setTimeout(() => this.pollMetrics(), 10_000);
1277
+ },
1278
+ error: () => (this.metricsTimer = setTimeout(() => this.pollMetrics(), 30_000)),
1279
+ });
1280
+ }
1281
+ setTab(t) {
1282
+ this.router.navigate([], { relativeTo: this.route, queryParams: { tab: t === 'overview' ? null : t }, queryParamsHandling: 'merge', replaceUrl: true });
1283
+ }
1284
+ sizeLine(d) {
1285
+ const [cpu, ram] = d.planLabel.split(' · ');
1286
+ return `${cpu} / ${ram} / ${d.diskGb} GB`;
1287
+ }
1288
+ snippetLabel(k) {
1289
+ return { uri: 'URI', node: 'Node' }[k] ?? k;
1290
+ }
1291
+ attState(a) {
1292
+ return a.state === 'live' ? 'Injected' : a.state === 'failed' ? 'Failed' : 'Redeploying…';
1293
+ }
1294
+ attColour(a) {
1295
+ return a.state === 'live' ? 'var(--o-ok-tx)' : a.state === 'failed' ? 'var(--o-bad-tx)' : 'var(--o-acc-tx)';
1296
+ }
1297
+ backupNote(b) {
1298
+ if (b.phase === 'failed')
1299
+ return 'failed';
1300
+ if (b.phase !== 'completed')
1301
+ return b.phase;
1302
+ if (b.startedAt && b.stoppedAt) {
1303
+ const s = Math.max(1, Math.round((Date.parse(b.stoppedAt) - Date.parse(b.startedAt)) / 1000));
1304
+ return s < 60 ? `${s} s` : `${Math.round(s / 60)} min`;
1305
+ }
1306
+ return '';
1307
+ }
1308
+ pitrHint(d) {
1309
+ const w = d.window;
1310
+ if (!w?.from)
1311
+ return 'Available after the first backup.';
1312
+ return `Any second since ${when(w.from)}${w.to ? ' until ' + when(w.to) : ''}.`;
1313
+ }
1314
+ keepLine(b, days) {
1315
+ if (b.kind === 'on-demand')
1316
+ return 'until deleted';
1317
+ return b.startedAt ? 'until ' + shortDate(new Date(Date.parse(b.startedAt) + days * 86400000).toISOString()) : `${days} days`;
1318
+ }
1319
+ backupDot(b) {
1320
+ return b.phase === 'failed' ? 'o-red' : b.phase !== 'completed' ? 'o-blu' : b.kind === 'on-demand' ? 'o-blu' : 'o-grn';
1321
+ }
1322
+ windowHours(h) {
1323
+ const p = (n) => String(n % 24).padStart(2, '0');
1324
+ return `${p(h)}:00 – ${p(h + 4)}:00 UTC`;
1325
+ }
1326
+ dialogTitle(dl, d) {
1327
+ return dl.kind === 'delete' ? `Delete ${d.name}?` : dl.kind === 'restore' ? 'Restore to a new instance' : 'Rotate credentials?';
1328
+ }
1329
+ // ---- actions --------------------------------------------------------------
1330
+ run(obs, done, failure) {
1331
+ this.busy.set(true);
1332
+ this.error.set('');
1333
+ this.notice.set('');
1334
+ obs.subscribe({
1335
+ next: (v) => {
1336
+ this.busy.set(false);
1337
+ done(v);
1338
+ this.load();
1339
+ },
1340
+ error: (e) => {
1341
+ this.busy.set(false);
1342
+ this.error.set(apiMessage(e, failure));
1343
+ },
1344
+ });
1345
+ }
1346
+ toggleReveal(d) {
1347
+ if (this.revealed()) {
1348
+ this.revealed.set(null);
1349
+ return;
1350
+ }
1351
+ this.run(this.api.reveal(this.key(), d.id), (r) => {
1352
+ this.revealed.set(r);
1353
+ this.notice.set('Credential reveal recorded in the audit log.');
1354
+ }, 'The password could not be revealed.');
1355
+ }
1356
+ attach(d) {
1357
+ const app = this.attApp().trim();
1358
+ this.run(this.api.attach(this.key(), d.id, { app, environment: this.attEnv(), prefix: this.attPrefix().trim() }), () => {
1359
+ this.attachOpen.set(false);
1360
+ this.notice.set(`${d.name} attached to ${app} · ${this.attEnv()}: ${this.attPrefix()}${this.envVar()} is injected and ${app} redeploys.`);
1361
+ }, 'The service could not be attached.');
1362
+ }
1363
+ detach(d, a) {
1364
+ this.run(this.api.detach(this.key(), d.id, a.id), () => this.notice.set(`${a.app} no longer gets ${a.prefix}${this.envVar()}; it redeploys without it.`), 'The app could not be detached.');
1365
+ }
1366
+ askRotate(d) {
1367
+ const owner = d.users?.find((u) => u.preset === 'owner') ?? d.users?.[0];
1368
+ if (owner)
1369
+ this.dialog.set({ kind: 'rotate', user: owner.name, grace: 60 });
1370
+ }
1371
+ rotate(d, user, grace) {
1372
+ this.run(this.api.rotate(this.key(), d.id, user, grace), () => {
1373
+ this.dialog.set(null);
1374
+ this.notice.set(`Rotating ${user}'s password: attached apps redeploy with the new one; the old one stays valid until they are healthy.`);
1375
+ }, 'The credentials could not be rotated.');
1376
+ }
1377
+ retire(d, r) {
1378
+ this.run(this.api.retire(this.key(), d.id, r.id), () => this.notice.set(`Generation ${r.fromGeneration} of ${r.user} no longer logs in.`), 'The old password could not be retired.');
1379
+ }
1380
+ addDb(d) {
1381
+ const name = this.dbName().trim();
1382
+ if (!name)
1383
+ return;
1384
+ this.run(this.api.addDatabase(this.key(), d.id, name), () => {
1385
+ this.addingDb.set(false);
1386
+ this.dbName.set('');
1387
+ this.notice.set(`Database ${name} created.`);
1388
+ }, 'The database could not be created.');
1389
+ }
1390
+ removeDb(d, name) {
1391
+ this.run(this.api.removeDatabase(this.key(), d.id, name), () => this.notice.set(`Database ${name} removed.`), 'The database could not be removed.');
1392
+ }
1393
+ addUser(d) {
1394
+ const name = this.userName().trim();
1395
+ if (!name)
1396
+ return;
1397
+ this.run(this.api.addUser(this.key(), d.id, name, this.userPreset()), () => {
1398
+ this.addingUser.set(false);
1399
+ this.userName.set('');
1400
+ this.notice.set(`${name} created with the ${this.userPreset()} preset. Reveal shows its password.`);
1401
+ }, 'The user could not be created.');
1402
+ }
1403
+ removeUser(d, name) {
1404
+ this.run(this.api.removeUser(this.key(), d.id, name), () => this.notice.set(`${name} can no longer log in.`), 'The user could not be removed.');
1405
+ }
1406
+ backupNow(d) {
1407
+ this.run(this.api.backupNow(this.key(), d.id), () => this.notice.set('On-demand backup started · kept until you delete it.'), 'The backup could not be started.');
1408
+ }
1409
+ deleteBackup(d, b) {
1410
+ this.run(this.api.deleteBackup(this.key(), d.id, b.name), () => this.notice.set('Backup deleted.'), 'The backup could not be deleted.');
1411
+ }
1412
+ restoreName(d) {
1413
+ return `${d.name}-restore-1`.slice(0, 40);
1414
+ }
1415
+ askRestore(d, b) {
1416
+ this.dialog.set({ kind: 'restore', name: this.restoreName(d), point: '', backup: b.name });
1417
+ }
1418
+ askPitr(d) {
1419
+ this.dialog.set({ kind: 'restore', name: this.restoreName(d), point: this.pitr(), backup: '' });
1420
+ }
1421
+ restore(d, dl) {
1422
+ const body = dl.point ? { pointInTime: fromLocalInput(dl.point), name: dl.name } : { backup: dl.backup, name: dl.name };
1423
+ this.busy.set(true);
1424
+ this.error.set('');
1425
+ this.api.restore(this.key(), d.id, body).subscribe({
1426
+ next: (r) => {
1427
+ this.busy.set(false);
1428
+ this.dialog.set(null);
1429
+ this.router.navigate(this.host.link('services/' + r.name));
1430
+ },
1431
+ error: (e) => {
1432
+ this.busy.set(false);
1433
+ this.dialog.set(null);
1434
+ this.error.set(apiMessage(e, 'The restore could not be started.'));
1435
+ },
1436
+ });
1437
+ }
1438
+ swap(d, from) {
1439
+ this.run(this.api.swap(this.key(), d.id, from), () => this.notice.set(`${from}'s apps now use ${d.name}; they redeploy with its connection.`), 'The apps could not be moved.');
1440
+ }
1441
+ save(d, body, ok) {
1442
+ this.run(this.api.update(this.key(), d.id, body), () => this.notice.set(ok), 'The change could not be saved.');
1443
+ }
1444
+ resize(d) {
1445
+ const body = {};
1446
+ if (this.newPlan() !== d.plan)
1447
+ body.plan = this.newPlan();
1448
+ if (this.newDisk() !== d.diskGb)
1449
+ body.diskGb = this.newDisk();
1450
+ const plan = this.plans().find((p) => p.id === this.newPlan());
1451
+ this.save(d, body, `Resizing ${d.name} to ${this.newPlan()} · ${plan ? plan.cpu + ' vCPU · ' + plan.memory.replace('Gi', ' GB') + ' · ' : ''}${this.newDisk()} GB${body.plan ? ' · brief failover expected' : ''}.`);
1452
+ }
1453
+ addCidr(d) {
1454
+ const c = this.cidr().trim();
1455
+ if (!c)
1456
+ return;
1457
+ const cidr = c.includes('/') ? c : c.includes(':') ? c + '/128' : c + '/32';
1458
+ this.save(d, { allowedCidrs: [...d.allowedCidrs, cidr] }, `${cidr} can reach the public endpoint.`);
1459
+ this.cidr.set('');
1460
+ }
1461
+ dropCidr(d, c) {
1462
+ this.save(d, { allowedCidrs: d.allowedCidrs.filter((x) => x !== c) }, `${c} removed from the trusted sources.`);
1463
+ }
1464
+ remove(d, finalBackup) {
1465
+ this.run(this.api.remove(this.key(), d.id, d.name, finalBackup), () => {
1466
+ this.dialog.set(null);
1467
+ this.notice.set(`${d.name} is scheduled for deletion · undo within 24 hours.`);
1468
+ }, 'The service could not be deleted.');
1469
+ }
1470
+ undelete(d) {
1471
+ this.run(this.api.undelete(this.key(), d.id), () => this.notice.set(`${d.name} is back; reattach its apps if they need it.`), 'The delete could not be undone.');
1472
+ }
1473
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicePage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1474
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ServicePage, isStandalone: true, selector: "o-service", ngImport: i0, template: `
1475
+ @if (d(); as d) {
1476
+ <div class="o-page">
1477
+ <div class="o-ph">
1478
+ <div class="o-col">
1479
+ <span class="o-eyebrow">{{ d.engineName }} {{ d.version }} · {{ sizeLine(d) }} · {{ region() }} · {{ d.environment || 'shared' }}</span>
1480
+ <div class="o-flex"><h1 class="o-t">{{ d.name }}</h1><span [class]="chip(d.state)" data-testid="svc-state">{{ d.state }}</span></div>
1481
+ </div>
1482
+ <div class="o-acts">
1483
+ @if (d.canManage && !d.deletedAt) {<button type="button" class="o-btn" (click)="askRotate(d)" [disabled]="busy() || !d.users?.length" data-testid="svc-rotate">Rotate credentials</button>}
1484
+ @if (d.canManage && !d.deletedAt) {<button type="button" class="o-btn" (click)="setTab('settings')">Resize</button>}
1485
+ <a class="o-btn" [routerLink]="host.link('dashboards')">Dashboard</a>
1486
+ </div>
1487
+ </div>
1488
+
1489
+ @if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-error">{{ error() }}</div>}
1490
+ @if (notice()) {<div class="o-note o-grn" role="status" data-testid="svc-notice">{{ notice() }}</div>}
1491
+ @if (d.message && d.state !== 'Running') {<div class="o-note" [class.o-red]="d.state === 'Failed'" role="status">{{ d.message }}</div>}
1492
+ @if (d.deletedAt) {
1493
+ <div class="o-card o-cb o-flex o-wrap" style="border-color:var(--o-bad-line)" data-testid="svc-deleted">
1494
+ <div class="o-col" style="gap:2px;flex:1"><b>{{ d.name }} is scheduled for deletion</b><span class="o-hint">Purged {{ when(d.purgeAt) }}. Until then you can undo; attached apps were detached.@if (d.finalBackup) { A final backup is kept for 7 days.}</span></div>
1495
+ @if (d.canManage) {<button type="button" class="o-btn" (click)="undelete(d)" [disabled]="busy()" data-testid="svc-undelete">Undo delete</button>}
1496
+ </div>
1497
+ }
1498
+ @if (d.restoreOf; as src) {
1499
+ <div class="o-card o-cb o-flex o-wrap" data-testid="svc-restored">
1500
+ <div class="o-col" style="gap:2px;flex:1"><b>Restored from <a [routerLink]="host.link('services/' + src.name)">{{ src.name }}</a></b><span class="o-hint">@if (d.restorePoint) {To {{ when(d.restorePoint) }}.} @else if (d.restoreBackup) {From backup {{ d.restoreBackup }}.} {{ src.name }} is untouched.@if (src.attached.length) { Its apps: {{ src.attached.join(', ') }}.}</span></div>
1501
+ @if (d.canManage && src.attached.length && d.state === 'Running') {<button type="button" class="o-btn" (click)="swap(d, src.name)" [disabled]="busy()" data-testid="svc-swap">Move {{ src.name }}'s apps here</button>}
1502
+ </div>
1503
+ }
1504
+
1505
+ <div class="o-tabs" role="tablist">
1506
+ @for (t of tabs; track t.id) {<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === t.id" [attr.aria-selected]="tab() === t.id" (click)="setTab(t.id)" [attr.data-testid]="'svc-tab-' + t.id">{{ t.label }}</button>}
1507
+ </div>
1508
+
1509
+ @switch (tab()) {
1510
+ @case ('overview') {
1511
+ <div class="o-g4">
1512
+ @for (s of stats(); track s.label) {<div class="o-stat"><span class="o-k">{{ s.label }}</span><span class="o-v">{{ s.value }}</span><span class="o-s">{{ s.sub }}</span></div>}
1513
+ </div>
1514
+ <div class="o-g2" style="align-items:start;gap:18px">
1515
+ <div class="o-card">
1516
+ <div class="o-ch"><b>Connection details</b><span class="o-r">Private network · TLS required</span></div>
1517
+ @if (conn(); as c) {
1518
+ <div class="o-kv o-cb" style="grid-template-columns:100px 1fr;row-gap:9px">
1519
+ <span class="o-k">Host</span><span class="o-mono o-sm" style="word-break:break-all">{{ c.host }}</span>
1520
+ <span class="o-k">Port</span><span class="o-mono o-sm">{{ c.port }}</span>
1521
+ @if (c.database) {<span class="o-k">Database</span><span class="o-mono o-sm">{{ c.database }}</span>}
1522
+ <span class="o-k">User</span><span class="o-mono o-sm">{{ c.user }}</span>
1523
+ <span class="o-k">Password</span>
1524
+ <span class="o-flex"><span class="o-mono o-sm" data-testid="svc-password">{{ revealed()?.password ?? '••••••••••••••••' }}</span>
1525
+ @if (d.canManage) {<button type="button" class="o-lnk" (click)="toggleReveal(d)" [disabled]="busy()" data-testid="svc-reveal">{{ revealed() ? 'Hide' : 'Reveal' }}</button>}
1526
+ @else {<span class="o-faint o-xs">Maintainers can reveal</span>}
1527
+ </span>
1528
+ <span class="o-k">SSL mode</span><span class="o-mono o-sm">{{ c.sslMode }}</span>
1529
+ </div>
1530
+ <div style="padding:0 14px 8px"><div class="o-seg">
1531
+ @for (k of snippetKeys(); track k) {<button type="button" [class.o-on]="snip() === k" (click)="snip.set(k)">{{ snippetLabel(k) }}</button>}
1532
+ </div></div>
1533
+ <div class="o-mono" style="margin:0 14px 14px;background:var(--o-bg);border-radius:6px;padding:10px 12px;font-size:12px;color:var(--o-tx-2);overflow-x:auto;white-space:pre" data-testid="svc-snippet">{{ c.snippets[snip()] ?? '' }}</div>
1534
+ } @else {<div class="o-empty">Connection details appear once {{ d.name }} is running.</div>}
1535
+ </div>
1536
+ <div class="o-col" style="gap:18px">
1537
+ <div class="o-card">
1538
+ <div class="o-ch"><b>Attached apps</b>@if (d.canManage && !d.deletedAt) {<button type="button" class="o-btn o-sm o-r" (click)="attachOpen.set(!attachOpen())" data-testid="svc-attach-open">Attach to app</button>}</div>
1539
+ @for (a of d.attachments; track a.id) {
1540
+ <div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
1541
+ <span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
1542
+ <span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
1543
+ @if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
1544
+ </div>
1545
+ } @empty {<div class="o-empty">Not attached. Attach it and the app gets {{ envVar() }} on its next release.</div>}
1546
+ @if (attachOpen()) {
1547
+ <div class="o-flex o-wrap" style="padding:12px 14px;background:var(--o-bg)">
1548
+ @if (apps().length) {
1549
+ <select class="o-in o-sm" style="width:auto" aria-label="App" [ngModel]="attApp()" (ngModelChange)="attApp.set($event)" data-testid="svc-att-app">@for (a of apps(); track a.name) {<option [value]="a.name">{{ a.name }}</option>}</select>
1550
+ } @else {<input class="o-in o-sm o-mono" style="width:120px" placeholder="app" aria-label="App" [ngModel]="attApp()" (ngModelChange)="attApp.set($event)" data-testid="svc-att-app" />}
1551
+ <select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="attEnv()" (ngModelChange)="attEnv.set($event)" data-testid="svc-att-env">@for (e of attEnvs(); track e) {<option [value]="e">{{ e }}</option>}</select>
1552
+ <input class="o-in o-sm o-mono" style="width:110px" placeholder="prefix (opt.)" aria-label="Prefix" [ngModel]="attPrefix()" (ngModelChange)="attPrefix.set($event)" />
1553
+ <span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
1554
+ <button type="button" class="o-btn o-sm o-pri o-r" (click)="attach(d)" [disabled]="busy() || !attApp()" data-testid="svc-att-go">Attach</button>
1555
+ </div>
1556
+ }
1557
+ <div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.canManage) { · <button type="button" class="o-lnk" (click)="retire(d, r)" [disabled]="busy()">Retire the old password now</button>}}</div>
1558
+ </div>
1559
+ <div class="o-card">
1560
+ <div class="o-ch"><b>Backups</b><span class="o-r">Daily · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
1561
+ @for (b of backups().slice(0, 3); track b.name) {
1562
+ <div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && d.canManage) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore to new instance</button>}</div>
1563
+ } @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
1564
+ <div class="o-cf">{{ windowLine() }}</div>
1565
+ </div>
1566
+ </div>
1567
+ </div>
1568
+ }
1569
+ @case ('users') {
1570
+ <div class="o-g2" style="align-items:start;gap:18px">
1571
+ <div class="o-card">
1572
+ <div class="o-ch"><b>Databases</b>@if (d.canManage && engine()?.databases) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-db-add">+ Database</button>}</div>
1573
+ @if (addingDb()) {
1574
+ <div class="o-row" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" placeholder="name" aria-label="Database name" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" (keydown.enter)="addDb(d)" data-testid="svc-db-name" /><button type="button" class="o-btn o-sm o-dark" (click)="addDb(d)" [disabled]="busy() || !dbName()" data-testid="svc-db-create">Create</button></div>
1575
+ }
1576
+ @for (db of d.databases; track db.name; let first = $first) {
1577
+ <div class="o-row"><span class="o-mono o-b5">{{ db.name }}</span><span class="o-faint o-sm">{{ first ? 'default' : '' }}</span><span class="o-r o-faint o-sm">{{ when(db.createdAt) }}</span>
1578
+ @if (d.canManage && !first) {<button type="button" class="o-lnk o-faint" (click)="removeDb(d, db.name)" [attr.aria-label]="'Remove ' + db.name" [disabled]="busy()">×</button>}</div>
1579
+ } @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
1580
+ </div>
1581
+ <div class="o-card">
1582
+ <div class="o-ch"><b>Users</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingUser.set(!addingUser())" data-testid="svc-user-add">+ User</button>}</div>
1583
+ @if (addingUser()) {
1584
+ <div class="o-row" style="background:var(--o-bg)">
1585
+ <input class="o-in o-sm o-mono" placeholder="name" aria-label="User name" [ngModel]="userName()" (ngModelChange)="userName.set($event)" data-testid="svc-user-name" />
1586
+ <select class="o-in o-sm" style="width:auto" aria-label="Role" [ngModel]="userPreset()" (ngModelChange)="userPreset.set($event)" data-testid="svc-user-preset">@for (p of presets; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select>
1587
+ <button type="button" class="o-btn o-sm o-dark" (click)="addUser(d)" [disabled]="busy() || !userName()" data-testid="svc-user-create">Create</button>
1588
+ </div>
1589
+ }
1590
+ @for (u of d.users ?? []; track u.name) {
1591
+ <div class="o-row"><span class="o-mono o-b5">{{ u.name }}</span><span [class]="preset(u.preset)">{{ u.preset }}</span>@if (u.rotating) {<span class="o-chip o-blu">rotating</span>}
1592
+ <span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
1593
+ @if (d.canManage && u.preset !== 'owner') {<button type="button" class="o-lnk o-faint" (click)="removeUser(d, u.name)" [attr.aria-label]="'Remove ' + u.name" [disabled]="busy()">×</button>}</div>
1594
+ }
1595
+ <div class="o-cf">Per-app users created on attach, each with least privilege<b class="o-ph-tag o-ga">GA</b></div>
1596
+ </div>
1597
+ </div>
1598
+ @if (d.engine === 'postgres') {
1599
+ <div class="o-card o-cb o-flex o-wrap"><div class="o-col" style="gap:2px"><b>Extensions</b><span class="o-hint">From the allow-list: pg_stat_statements, pgcrypto, uuid-ossp, citext, pg_trgm, postgis</span></div><div class="o-flex o-r"><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-sm" disabled>+ Enable</button></div></div>
1600
+ }
1601
+ }
1602
+ @case ('backups') {
1603
+ <div class="o-split o-w340">
1604
+ <div class="o-card">
1605
+ <div class="o-ch"><b>Backups</b>@if (d.canManage && !d.deletedAt) {<span class="o-r"><button type="button" class="o-btn o-sm" (click)="backupNow(d)" [disabled]="busy() || d.state !== 'Running'" data-testid="svc-backup-now">Back up now</button></span>}</div>
1606
+ @for (b of backups(); track b.name) {
1607
+ <div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
1608
+ <span [class]="'o-dot ' + backupDot(b)"></span><span style="width:150px">{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint o-sm" style="width:80px">{{ backupNote(b) }}</span>
1609
+ <span class="o-chip">{{ kind(b) }}</span><span class="o-faint o-sm">{{ keepLine(b, d.retentionDays) }}</span>
1610
+ @if (b.phase === 'completed' && d.canManage) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore</button>}
1611
+ @if (b.kind === 'on-demand' && d.canManage && b.phase !== 'running') {<button type="button" class="o-lnk o-faint" [class.o-r]="b.phase !== 'completed'" (click)="deleteBackup(d, b)" [attr.aria-label]="'Delete ' + b.name" [disabled]="busy()">×</button>}
1612
+ </div>
1613
+ } @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
1614
+ <div class="o-cf">Stored encrypted in a separate failure domain. Cross-region copies<b class="o-ph-tag o-later">Later</b></div>
1615
+ </div>
1616
+ <div class="o-col" style="gap:14px">
1617
+ @if (d.pitr) {
1618
+ <div class="o-card o-cb o-col" style="gap:10px">
1619
+ <b>Restore to a point in time</b>
1620
+ <span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
1621
+ <input class="o-in o-mono" type="datetime-local" step="1" aria-label="Point in time" [ngModel]="pitr()" (ngModelChange)="pitr.set($event)" data-testid="svc-pitr" />
1622
+ <button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!d.canManage || !pitr() || !d.window?.from" data-testid="svc-pitr-go">Restore…</button>
1623
+ </div>
1624
+ } @else {
1625
+ <div class="o-card o-cb o-col" style="gap:6px"><b>Restore a snapshot</b><span class="o-hint">{{ d.engineName }} restores from a daily RDB snapshot: pick one on the left. It always creates a new instance.</span></div>
1626
+ }
1627
+ <div class="o-card o-cb o-form">
1628
+ <b>Schedule</b>
1629
+ <div class="o-g2">
1630
+ <div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.canManage" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
1631
+ <div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.canManage" [ngModel]="d.retentionDays" (ngModelChange)="save(d, { retentionDays: +$event }, 'Backups are now kept ' + $event + ' days.')">@for (k of keeps; track k) {<option [ngValue]="k">{{ k }} days</option>}</select></div>
1632
+ </div>
1633
+ </div>
1634
+ <div class="o-card o-cb o-col" style="gap:8px"><div class="o-flex"><b>Fork for staging</b><b class="o-ph-tag o-ga">GA</b></div><span class="o-hint">Copy from the latest backup into a new instance.</span><div class="o-flex"><b>Export dump</b><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-xs o-r" disabled>{{ d.engine === 'mysql' ? 'mysqldump' : d.engine === 'valkey' ? 'RDB' : 'pg_dump' }}</button></div></div>
1635
+ </div>
1636
+ </div>
1637
+ }
1638
+ @case ('metrics') {
1639
+ <o-svc-metrics [readings]="readings()" [slowLines]="slowLines()" [engine]="d.engine" [projectKey]="key()" [service]="d.name" [dashboard]="host.link('dashboards')" />
1640
+ }
1641
+ @case ('logs') {
1642
+ <o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
1643
+ }
1644
+ @case ('console') {
1645
+ <div class="o-flex o-wrap"><span class="o-chip o-grn">Read-only</span><span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as console_ro · audited</span><b class="o-ph-tag o-ga">GA</b><span class="o-r"></span>
1646
+ @if (d.databases.length) {<select class="o-in o-sm" style="width:auto" aria-label="Database" disabled>@for (db of d.databases; track db.name) {<option>{{ db.name }}</option>}</select>}</div>
1647
+ <div class="o-card"><textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" disabled aria-label="Query">{{ d.engine === 'valkey' ? 'INFO keyspace' : 'SELECT * FROM … LIMIT 50' }}</textarea><div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" disabled>Run</button></div></div>
1648
+ <span class="o-faint o-sm">The console arrives at GA. Until then, connect with the snippets on Overview from an attached app, or reveal the password.</span>
1649
+ }
1650
+ @case ('settings') {
1651
+ <div class="o-col" style="gap:14px;max-width:820px">
1652
+ <div class="o-card">
1653
+ <div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
1654
+ <div class="o-cb o-flex o-wrap">
1655
+ <select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!d.canManage" [ngModel]="newPlan()" (ngModelChange)="newPlan.set($event)" data-testid="svc-plan">
1656
+ @for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
1657
+ </select>
1658
+ <label class="o-flex o-sm" style="gap:6px">Disk <input class="o-in o-sm o-mono" style="width:80px" type="number" [min]="d.diskGb" [max]="catalog()?.maxDiskGb ?? 2000" [disabled]="!d.canManage" [ngModel]="newDisk()" (ngModelChange)="newDisk.set(+$event)" data-testid="svc-disk" /> GB</label>
1659
+ <button type="button" class="o-btn o-dark" (click)="resize(d)" [disabled]="busy() || !d.canManage || (newPlan() === d.plan && newDisk() === d.diskGb)" data-testid="svc-resize">Resize</button>
1660
+ </div>
1661
+ <div class="o-cf"><button type="button" class="o-tog" disabled aria-label="Grow storage automatically (GA)"></button>Grow storage automatically at 85% full, up to 500 GB<b class="o-ph-tag o-ga">GA</b></div>
1662
+ </div>
1663
+ <div class="o-card"><div class="o-ch"><b>High availability</b><b class="o-ph-tag o-ga">GA</b></div><div class="o-row"><span style="flex:1">Standby node with automatic failover</span><button type="button" class="o-tog" disabled aria-label="Standby (GA)"></button></div><div class="o-row"><span style="flex:1">Read replicas</span><span class="o-faint o-sm">0</span><button type="button" class="o-btn o-xs" disabled>+ Replica</button></div></div>
1664
+ <div class="o-card">
1665
+ <div class="o-ch"><b>Networking</b></div>
1666
+ <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>
1667
+ <div class="o-row"><span style="flex:1">Public endpoint with trusted sources</span><button type="button" class="o-tog" [class.o-on]="d.publicEnabled" [disabled]="!d.canManage || busy()" (click)="save(d, { publicEnabled: !d.publicEnabled }, d.publicEnabled ? 'The public endpoint is off.' : 'The public endpoint is on for the trusted sources below.')" aria-label="Public endpoint" data-testid="svc-public"></button></div>
1668
+ @if (d.publicEnabled) {
1669
+ <div class="o-row o-wrap" style="background:var(--o-bg)">
1670
+ @for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (d.canManage) { <button type="button" class="o-lnk o-faint" (click)="dropCidr(d, c)" [attr.aria-label]="'Remove ' + c">×</button>}</span>}
1671
+ @if (d.canManage) {<input class="o-in o-sm o-mono" style="width:180px" placeholder="+ IP or CIDR" aria-label="Add a trusted source" [ngModel]="cidr()" (ngModelChange)="cidr.set($event)" (keydown.enter)="addCidr(d)" data-testid="svc-cidr" />}
1672
+ <span class="o-faint o-sm o-r">TLS required</span>
1673
+ </div>
1674
+ }
1675
+ </div>
1676
+ <div class="o-card">
1677
+ <div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
1678
+ <div class="o-cb o-flex o-wrap">
1679
+ <select class="o-in" style="width:auto" aria-label="Day" [disabled]="!d.canManage" [ngModel]="d.maintenanceDay" (ngModelChange)="save(d, { maintenanceDay: $event }, 'Maintenance window saved.')">@for (x of days; track x.id) {<option [value]="x.id">{{ x.label }}</option>}</select>
1680
+ <select class="o-in" style="width:auto" aria-label="Hours" [disabled]="!d.canManage" [ngModel]="d.maintenanceHour" (ngModelChange)="save(d, { maintenanceHour: +$event }, 'Maintenance window saved.')">@for (h of hours; track h) {<option [ngValue]="h">{{ windowHours(h) }}</option>}</select>
1681
+ <span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
1682
+ </div>
1683
+ </div>
1684
+ <div class="o-card"><div class="o-ch"><b>Engine parameters</b><b class="o-ph-tag o-ga">GA</b><span class="o-r">validated subset</span></div><div class="o-tbl"><div class="o-tbody">
1685
+ @for (p of params(); track p[0]) {<div class="o-tr"><div class="o-td o-mono o-sm">{{ p[0] }}</div><div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [value]="p[1]" disabled [attr.aria-label]="p[0]" /></div><div class="o-td"><span class="o-chip" [class.o-amb]="p[2] === 'restart'">{{ p[2] }}</span></div></div>}
1686
+ </div></div></div>
1687
+ @if (d.canManage && !d.deletedAt) {
1688
+ <div class="o-card" style="border-color:var(--o-bad-line)"><div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>Delete {{ d.name }}</b><span class="o-hint">Typed confirmation. Undo within 24 hours; optional final backup kept 7 days. Attached apps lose {{ envVar() }}.</span></div><button type="button" class="o-btn o-sm o-red" (click)="dialog.set({ kind: 'delete', typed: '', finalBackup: true })" data-testid="svc-delete">Delete</button></div></div>
1689
+ }
1690
+ </div>
1691
+ }
1692
+ }
1693
+ </div>
1694
+
1695
+ @if (dialog(); as dl) {
1696
+ <div class="o-overlay o-mid" (click)="dialog.set(null)">
1697
+ <div class="o-dlg" role="dialog" aria-modal="true" [attr.aria-label]="dialogTitle(dl, d)" (click)="$event.stopPropagation()" (keydown.escape)="dialog.set(null)">
1698
+ <div class="o-dlg-h"><b>{{ dialogTitle(dl, d) }}</b></div>
1699
+ <div class="o-dlg-b">
1700
+ @switch (dl.kind) {
1701
+ @case ('delete') {
1702
+ <span>Attached apps lose {{ envVar() }} and redeploy. You can undo within 24 hours.</span>
1703
+ <label class="o-flex o-sm"><input type="checkbox" [(ngModel)]="dl.finalBackup" /> Take a final backup and keep it 7 days</label>
1704
+ <label class="o-fld"><span class="o-sm">Type <b class="o-mono">{{ d.name }}</b> to confirm</span><input class="o-in o-mono" [(ngModel)]="dl.typed" data-testid="svc-delete-typed" autocomplete="off" /></label>
1705
+ }
1706
+ @case ('restore') {
1707
+ <span>{{ dl.point ? 'Restores ' + d.name + ' as it was at ' + whenLocal(dl.point) : 'Restores backup ' + dl.backup }} into a new instance with the same users and passwords. {{ d.name }} keeps running untouched; move its apps over afterwards if you want.</span>
1708
+ <label class="o-fld"><span class="o-sm">New instance</span><input class="o-in o-mono" [(ngModel)]="dl.name" data-testid="svc-restore-name" autocomplete="off" /></label>
1709
+ }
1710
+ @case ('rotate') {
1711
+ <span>A new password for <b class="o-mono">{{ dl.user }}</b> is created beside the current one. Every attached app redeploys with it; once all are healthy the old one stays valid for the grace period, then stops. If one fails, the old password stays.</span>
1712
+ <label class="o-fld"><span class="o-sm">Keep the old password after the apps are healthy</span>
1713
+ <select class="o-in" [(ngModel)]="dl.grace">@for (g of graces; track g.m) {<option [ngValue]="g.m">{{ g.label }}</option>}</select></label>
1714
+ }
1715
+ }
1716
+ </div>
1717
+ <div class="o-dlg-f">
1718
+ <button type="button" class="o-btn" (click)="dialog.set(null)">Cancel</button>
1719
+ @switch (dl.kind) {
1720
+ @case ('delete') {<button type="button" class="o-btn o-red" [disabled]="busy() || dl.typed !== d.name" (click)="remove(d, dl.finalBackup)" data-testid="svc-delete-go">Delete service</button>}
1721
+ @case ('restore') {<button type="button" class="o-btn o-dark" [disabled]="busy() || !dl.name" (click)="restore(d, dl)" data-testid="svc-restore-go">Restore</button>}
1722
+ @case ('rotate') {<button type="button" class="o-btn o-dark" [disabled]="busy()" (click)="rotate(d, dl.user, dl.grace)" data-testid="svc-rotate-go">Rotate</button>}
1723
+ }
1724
+ </div>
1725
+ </div>
1726
+ </div>
1727
+ }
1728
+ } @else {
1729
+ <div class="o-page">@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>} @else {<div class="o-empty">Loading…</div>}</div>
1730
+ }
1731
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ServiceMetricsTab, selector: "o-svc-metrics", inputs: ["readings", "slowLines", "engine", "projectKey", "service", "dashboard"] }, { kind: "component", type: ServiceLogsTab, selector: "o-svc-logs", inputs: ["projectKey", "id", "service", "environment", "debugLogs"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1732
+ }
1733
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ServicePage, decorators: [{
1734
+ type: Component,
1735
+ args: [{
1736
+ selector: 'o-service',
1737
+ imports: [FormsModule, RouterLink, ServiceMetricsTab, ServiceLogsTab],
1738
+ changeDetection: ChangeDetectionStrategy.OnPush,
1739
+ template: `
1740
+ @if (d(); as d) {
1741
+ <div class="o-page">
1742
+ <div class="o-ph">
1743
+ <div class="o-col">
1744
+ <span class="o-eyebrow">{{ d.engineName }} {{ d.version }} · {{ sizeLine(d) }} · {{ region() }} · {{ d.environment || 'shared' }}</span>
1745
+ <div class="o-flex"><h1 class="o-t">{{ d.name }}</h1><span [class]="chip(d.state)" data-testid="svc-state">{{ d.state }}</span></div>
1746
+ </div>
1747
+ <div class="o-acts">
1748
+ @if (d.canManage && !d.deletedAt) {<button type="button" class="o-btn" (click)="askRotate(d)" [disabled]="busy() || !d.users?.length" data-testid="svc-rotate">Rotate credentials</button>}
1749
+ @if (d.canManage && !d.deletedAt) {<button type="button" class="o-btn" (click)="setTab('settings')">Resize</button>}
1750
+ <a class="o-btn" [routerLink]="host.link('dashboards')">Dashboard</a>
1751
+ </div>
1752
+ </div>
1753
+
1754
+ @if (error()) {<div class="o-note o-red" role="alert" data-testid="svc-error">{{ error() }}</div>}
1755
+ @if (notice()) {<div class="o-note o-grn" role="status" data-testid="svc-notice">{{ notice() }}</div>}
1756
+ @if (d.message && d.state !== 'Running') {<div class="o-note" [class.o-red]="d.state === 'Failed'" role="status">{{ d.message }}</div>}
1757
+ @if (d.deletedAt) {
1758
+ <div class="o-card o-cb o-flex o-wrap" style="border-color:var(--o-bad-line)" data-testid="svc-deleted">
1759
+ <div class="o-col" style="gap:2px;flex:1"><b>{{ d.name }} is scheduled for deletion</b><span class="o-hint">Purged {{ when(d.purgeAt) }}. Until then you can undo; attached apps were detached.@if (d.finalBackup) { A final backup is kept for 7 days.}</span></div>
1760
+ @if (d.canManage) {<button type="button" class="o-btn" (click)="undelete(d)" [disabled]="busy()" data-testid="svc-undelete">Undo delete</button>}
1761
+ </div>
1762
+ }
1763
+ @if (d.restoreOf; as src) {
1764
+ <div class="o-card o-cb o-flex o-wrap" data-testid="svc-restored">
1765
+ <div class="o-col" style="gap:2px;flex:1"><b>Restored from <a [routerLink]="host.link('services/' + src.name)">{{ src.name }}</a></b><span class="o-hint">@if (d.restorePoint) {To {{ when(d.restorePoint) }}.} @else if (d.restoreBackup) {From backup {{ d.restoreBackup }}.} {{ src.name }} is untouched.@if (src.attached.length) { Its apps: {{ src.attached.join(', ') }}.}</span></div>
1766
+ @if (d.canManage && src.attached.length && d.state === 'Running') {<button type="button" class="o-btn" (click)="swap(d, src.name)" [disabled]="busy()" data-testid="svc-swap">Move {{ src.name }}'s apps here</button>}
1767
+ </div>
1768
+ }
1769
+
1770
+ <div class="o-tabs" role="tablist">
1771
+ @for (t of tabs; track t.id) {<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === t.id" [attr.aria-selected]="tab() === t.id" (click)="setTab(t.id)" [attr.data-testid]="'svc-tab-' + t.id">{{ t.label }}</button>}
1772
+ </div>
1773
+
1774
+ @switch (tab()) {
1775
+ @case ('overview') {
1776
+ <div class="o-g4">
1777
+ @for (s of stats(); track s.label) {<div class="o-stat"><span class="o-k">{{ s.label }}</span><span class="o-v">{{ s.value }}</span><span class="o-s">{{ s.sub }}</span></div>}
1778
+ </div>
1779
+ <div class="o-g2" style="align-items:start;gap:18px">
1780
+ <div class="o-card">
1781
+ <div class="o-ch"><b>Connection details</b><span class="o-r">Private network · TLS required</span></div>
1782
+ @if (conn(); as c) {
1783
+ <div class="o-kv o-cb" style="grid-template-columns:100px 1fr;row-gap:9px">
1784
+ <span class="o-k">Host</span><span class="o-mono o-sm" style="word-break:break-all">{{ c.host }}</span>
1785
+ <span class="o-k">Port</span><span class="o-mono o-sm">{{ c.port }}</span>
1786
+ @if (c.database) {<span class="o-k">Database</span><span class="o-mono o-sm">{{ c.database }}</span>}
1787
+ <span class="o-k">User</span><span class="o-mono o-sm">{{ c.user }}</span>
1788
+ <span class="o-k">Password</span>
1789
+ <span class="o-flex"><span class="o-mono o-sm" data-testid="svc-password">{{ revealed()?.password ?? '••••••••••••••••' }}</span>
1790
+ @if (d.canManage) {<button type="button" class="o-lnk" (click)="toggleReveal(d)" [disabled]="busy()" data-testid="svc-reveal">{{ revealed() ? 'Hide' : 'Reveal' }}</button>}
1791
+ @else {<span class="o-faint o-xs">Maintainers can reveal</span>}
1792
+ </span>
1793
+ <span class="o-k">SSL mode</span><span class="o-mono o-sm">{{ c.sslMode }}</span>
1794
+ </div>
1795
+ <div style="padding:0 14px 8px"><div class="o-seg">
1796
+ @for (k of snippetKeys(); track k) {<button type="button" [class.o-on]="snip() === k" (click)="snip.set(k)">{{ snippetLabel(k) }}</button>}
1797
+ </div></div>
1798
+ <div class="o-mono" style="margin:0 14px 14px;background:var(--o-bg);border-radius:6px;padding:10px 12px;font-size:12px;color:var(--o-tx-2);overflow-x:auto;white-space:pre" data-testid="svc-snippet">{{ c.snippets[snip()] ?? '' }}</div>
1799
+ } @else {<div class="o-empty">Connection details appear once {{ d.name }} is running.</div>}
1800
+ </div>
1801
+ <div class="o-col" style="gap:18px">
1802
+ <div class="o-card">
1803
+ <div class="o-ch"><b>Attached apps</b>@if (d.canManage && !d.deletedAt) {<button type="button" class="o-btn o-sm o-r" (click)="attachOpen.set(!attachOpen())" data-testid="svc-attach-open">Attach to app</button>}</div>
1804
+ @for (a of d.attachments; track a.id) {
1805
+ <div class="o-row" [attr.data-testid]="'svc-att-' + a.app">
1806
+ <span class="o-b5">{{ a.app }}</span><span class="o-faint">{{ a.environment }}</span><span class="o-chip o-mono">{{ a.prefix }}{{ envVar() }}</span>
1807
+ <span class="o-r o-sm" [style.color]="attColour(a)" [attr.title]="a.message || null">{{ attState(a) }}</span>
1808
+ @if (d.canManage) {<button type="button" class="o-lnk o-faint" (click)="detach(d, a)" [attr.aria-label]="'Detach ' + a.app" [disabled]="busy()">×</button>}
1809
+ </div>
1810
+ } @empty {<div class="o-empty">Not attached. Attach it and the app gets {{ envVar() }} on its next release.</div>}
1811
+ @if (attachOpen()) {
1812
+ <div class="o-flex o-wrap" style="padding:12px 14px;background:var(--o-bg)">
1813
+ @if (apps().length) {
1814
+ <select class="o-in o-sm" style="width:auto" aria-label="App" [ngModel]="attApp()" (ngModelChange)="attApp.set($event)" data-testid="svc-att-app">@for (a of apps(); track a.name) {<option [value]="a.name">{{ a.name }}</option>}</select>
1815
+ } @else {<input class="o-in o-sm o-mono" style="width:120px" placeholder="app" aria-label="App" [ngModel]="attApp()" (ngModelChange)="attApp.set($event)" data-testid="svc-att-app" />}
1816
+ <select class="o-in o-sm" style="width:auto" aria-label="Environment" [ngModel]="attEnv()" (ngModelChange)="attEnv.set($event)" data-testid="svc-att-env">@for (e of attEnvs(); track e) {<option [value]="e">{{ e }}</option>}</select>
1817
+ <input class="o-in o-sm o-mono" style="width:110px" placeholder="prefix (opt.)" aria-label="Prefix" [ngModel]="attPrefix()" (ngModelChange)="attPrefix.set($event)" />
1818
+ <span class="o-mut o-sm">Injects {{ attPrefix() }}{{ envVar() }} and redeploys</span>
1819
+ <button type="button" class="o-btn o-sm o-pri o-r" (click)="attach(d)" [disabled]="busy() || !attApp()" data-testid="svc-att-go">Attach</button>
1820
+ </div>
1821
+ }
1822
+ <div class="o-cf">{{ rotateNote() }}@if (openRotation(); as r) {@if (d.canManage) { · <button type="button" class="o-lnk" (click)="retire(d, r)" [disabled]="busy()">Retire the old password now</button>}}</div>
1823
+ </div>
1824
+ <div class="o-card">
1825
+ <div class="o-ch"><b>Backups</b><span class="o-r">Daily · {{ d.retentionDays }}-day retention{{ d.pitr ? ' · PITR' : '' }}</span></div>
1826
+ @for (b of backups().slice(0, 3); track b.name) {
1827
+ <div class="o-row"><span>{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint">{{ backupNote(b) }}</span><span class="o-chip">{{ kind(b) }}</span>@if (b.phase === 'completed' && d.canManage) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore to new instance</button>}</div>
1828
+ } @empty {<div class="o-empty">The first backup is taken once {{ d.name }} is running.</div>}
1829
+ <div class="o-cf">{{ windowLine() }}</div>
1830
+ </div>
1831
+ </div>
1832
+ </div>
1833
+ }
1834
+ @case ('users') {
1835
+ <div class="o-g2" style="align-items:start;gap:18px">
1836
+ <div class="o-card">
1837
+ <div class="o-ch"><b>Databases</b>@if (d.canManage && engine()?.databases) {<button type="button" class="o-btn o-sm o-r" (click)="addingDb.set(!addingDb())" data-testid="svc-db-add">+ Database</button>}</div>
1838
+ @if (addingDb()) {
1839
+ <div class="o-row" style="background:var(--o-bg)"><input class="o-in o-sm o-mono" placeholder="name" aria-label="Database name" [ngModel]="dbName()" (ngModelChange)="dbName.set($event)" (keydown.enter)="addDb(d)" data-testid="svc-db-name" /><button type="button" class="o-btn o-sm o-dark" (click)="addDb(d)" [disabled]="busy() || !dbName()" data-testid="svc-db-create">Create</button></div>
1840
+ }
1841
+ @for (db of d.databases; track db.name; let first = $first) {
1842
+ <div class="o-row"><span class="o-mono o-b5">{{ db.name }}</span><span class="o-faint o-sm">{{ first ? 'default' : '' }}</span><span class="o-r o-faint o-sm">{{ when(db.createdAt) }}</span>
1843
+ @if (d.canManage && !first) {<button type="button" class="o-lnk o-faint" (click)="removeDb(d, db.name)" [attr.aria-label]="'Remove ' + db.name" [disabled]="busy()">×</button>}</div>
1844
+ } @empty {<div class="o-empty">{{ engine()?.databases ? 'No databases yet.' : d.engineName + ' has no logical databases: keys live in database 0.' }}</div>}
1845
+ </div>
1846
+ <div class="o-card">
1847
+ <div class="o-ch"><b>Users</b>@if (d.canManage) {<button type="button" class="o-btn o-sm o-r" (click)="addingUser.set(!addingUser())" data-testid="svc-user-add">+ User</button>}</div>
1848
+ @if (addingUser()) {
1849
+ <div class="o-row" style="background:var(--o-bg)">
1850
+ <input class="o-in o-sm o-mono" placeholder="name" aria-label="User name" [ngModel]="userName()" (ngModelChange)="userName.set($event)" data-testid="svc-user-name" />
1851
+ <select class="o-in o-sm" style="width:auto" aria-label="Role" [ngModel]="userPreset()" (ngModelChange)="userPreset.set($event)" data-testid="svc-user-preset">@for (p of presets; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select>
1852
+ <button type="button" class="o-btn o-sm o-dark" (click)="addUser(d)" [disabled]="busy() || !userName()" data-testid="svc-user-create">Create</button>
1853
+ </div>
1854
+ }
1855
+ @for (u of d.users ?? []; track u.name) {
1856
+ <div class="o-row"><span class="o-mono o-b5">{{ u.name }}</span><span [class]="preset(u.preset)">{{ u.preset }}</span>@if (u.rotating) {<span class="o-chip o-blu">rotating</span>}
1857
+ <span class="o-r o-faint o-sm o-ell">{{ u.attached.length ? 'used by ' + u.attached.join(', ') : 'generation ' + u.generation }}</span>
1858
+ @if (d.canManage && u.preset !== 'owner') {<button type="button" class="o-lnk o-faint" (click)="removeUser(d, u.name)" [attr.aria-label]="'Remove ' + u.name" [disabled]="busy()">×</button>}</div>
1859
+ }
1860
+ <div class="o-cf">Per-app users created on attach, each with least privilege<b class="o-ph-tag o-ga">GA</b></div>
1861
+ </div>
1862
+ </div>
1863
+ @if (d.engine === 'postgres') {
1864
+ <div class="o-card o-cb o-flex o-wrap"><div class="o-col" style="gap:2px"><b>Extensions</b><span class="o-hint">From the allow-list: pg_stat_statements, pgcrypto, uuid-ossp, citext, pg_trgm, postgis</span></div><div class="o-flex o-r"><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-sm" disabled>+ Enable</button></div></div>
1865
+ }
1866
+ }
1867
+ @case ('backups') {
1868
+ <div class="o-split o-w340">
1869
+ <div class="o-card">
1870
+ <div class="o-ch"><b>Backups</b>@if (d.canManage && !d.deletedAt) {<span class="o-r"><button type="button" class="o-btn o-sm" (click)="backupNow(d)" [disabled]="busy() || d.state !== 'Running'" data-testid="svc-backup-now">Back up now</button></span>}</div>
1871
+ @for (b of backups(); track b.name) {
1872
+ <div class="o-row" [attr.data-testid]="'svc-backup-' + b.name">
1873
+ <span [class]="'o-dot ' + backupDot(b)"></span><span style="width:150px">{{ when(b.startedAt) || 'Pending' }}</span><span class="o-faint o-sm" style="width:80px">{{ backupNote(b) }}</span>
1874
+ <span class="o-chip">{{ kind(b) }}</span><span class="o-faint o-sm">{{ keepLine(b, d.retentionDays) }}</span>
1875
+ @if (b.phase === 'completed' && d.canManage) {<button type="button" class="o-lnk o-r" (click)="askRestore(d, b)">Restore</button>}
1876
+ @if (b.kind === 'on-demand' && d.canManage && b.phase !== 'running') {<button type="button" class="o-lnk o-faint" [class.o-r]="b.phase !== 'completed'" (click)="deleteBackup(d, b)" [attr.aria-label]="'Delete ' + b.name" [disabled]="busy()">×</button>}
1877
+ </div>
1878
+ } @empty {<div class="o-empty">No backups yet. The first is taken once {{ d.name }} is running.</div>}
1879
+ <div class="o-cf">Stored encrypted in a separate failure domain. Cross-region copies<b class="o-ph-tag o-later">Later</b></div>
1880
+ </div>
1881
+ <div class="o-col" style="gap:14px">
1882
+ @if (d.pitr) {
1883
+ <div class="o-card o-cb o-col" style="gap:10px">
1884
+ <b>Restore to a point in time</b>
1885
+ <span class="o-hint">{{ pitrHint(d) }} Restores always create a new instance; {{ d.name }} is untouched.</span>
1886
+ <input class="o-in o-mono" type="datetime-local" step="1" aria-label="Point in time" [ngModel]="pitr()" (ngModelChange)="pitr.set($event)" data-testid="svc-pitr" />
1887
+ <button type="button" class="o-btn o-dark" (click)="askPitr(d)" [disabled]="!d.canManage || !pitr() || !d.window?.from" data-testid="svc-pitr-go">Restore…</button>
1888
+ </div>
1889
+ } @else {
1890
+ <div class="o-card o-cb o-col" style="gap:6px"><b>Restore a snapshot</b><span class="o-hint">{{ d.engineName }} restores from a daily RDB snapshot: pick one on the left. It always creates a new instance.</span></div>
1891
+ }
1892
+ <div class="o-card o-cb o-form">
1893
+ <b>Schedule</b>
1894
+ <div class="o-g2">
1895
+ <div class="o-fld"><label for="svc-bh">Daily at</label><select id="svc-bh" class="o-in" [disabled]="!d.canManage" [ngModel]="d.backupHour" (ngModelChange)="save(d, { backupHour: +$event }, 'Backups now run daily at ' + utc(+$event) + '.')">@for (h of hours; track h) {<option [ngValue]="h">{{ utc(h) }}</option>}</select></div>
1896
+ <div class="o-fld"><label for="svc-rd">Keep for</label><select id="svc-rd" class="o-in" [disabled]="!d.canManage" [ngModel]="d.retentionDays" (ngModelChange)="save(d, { retentionDays: +$event }, 'Backups are now kept ' + $event + ' days.')">@for (k of keeps; track k) {<option [ngValue]="k">{{ k }} days</option>}</select></div>
1897
+ </div>
1898
+ </div>
1899
+ <div class="o-card o-cb o-col" style="gap:8px"><div class="o-flex"><b>Fork for staging</b><b class="o-ph-tag o-ga">GA</b></div><span class="o-hint">Copy from the latest backup into a new instance.</span><div class="o-flex"><b>Export dump</b><b class="o-ph-tag o-ga">GA</b><button type="button" class="o-btn o-xs o-r" disabled>{{ d.engine === 'mysql' ? 'mysqldump' : d.engine === 'valkey' ? 'RDB' : 'pg_dump' }}</button></div></div>
1900
+ </div>
1901
+ </div>
1902
+ }
1903
+ @case ('metrics') {
1904
+ <o-svc-metrics [readings]="readings()" [slowLines]="slowLines()" [engine]="d.engine" [projectKey]="key()" [service]="d.name" [dashboard]="host.link('dashboards')" />
1905
+ }
1906
+ @case ('logs') {
1907
+ <o-svc-logs [projectKey]="key()" [id]="d.id" [service]="d.name" [environment]="d.environment" [debugLogs]="host.link('logs')" />
1908
+ }
1909
+ @case ('console') {
1910
+ <div class="o-flex o-wrap"><span class="o-chip o-grn">Read-only</span><span class="o-faint o-sm">1,000-row limit · 5 s statement timeout · runs as console_ro · audited</span><b class="o-ph-tag o-ga">GA</b><span class="o-r"></span>
1911
+ @if (d.databases.length) {<select class="o-in o-sm" style="width:auto" aria-label="Database" disabled>@for (db of d.databases; track db.name) {<option>{{ db.name }}</option>}</select>}</div>
1912
+ <div class="o-card"><textarea class="o-in o-mono" style="border:none;box-shadow:none;font-size:12.5px" rows="4" disabled aria-label="Query">{{ d.engine === 'valkey' ? 'INFO keyspace' : 'SELECT * FROM … LIMIT 50' }}</textarea><div class="o-cf"><span class="o-kbd">⌘</span><span class="o-kbd">↵</span> to run<button type="button" class="o-btn o-sm o-dark o-r" disabled>Run</button></div></div>
1913
+ <span class="o-faint o-sm">The console arrives at GA. Until then, connect with the snippets on Overview from an attached app, or reveal the password.</span>
1914
+ }
1915
+ @case ('settings') {
1916
+ <div class="o-col" style="gap:14px;max-width:820px">
1917
+ <div class="o-card">
1918
+ <div class="o-ch"><b>Resize</b><span class="o-r">vCPU and RAM: brief failover · disk grows online</span></div>
1919
+ <div class="o-cb o-flex o-wrap">
1920
+ <select class="o-in" style="max-width:360px" aria-label="Plan" [disabled]="!d.canManage" [ngModel]="newPlan()" (ngModelChange)="newPlan.set($event)" data-testid="svc-plan">
1921
+ @for (p of plans(); track p.id) {<option [value]="p.id">{{ p.id }} · {{ p.label }} · {{ price(p.priceCents) }}/mo</option>}
1922
+ </select>
1923
+ <label class="o-flex o-sm" style="gap:6px">Disk <input class="o-in o-sm o-mono" style="width:80px" type="number" [min]="d.diskGb" [max]="catalog()?.maxDiskGb ?? 2000" [disabled]="!d.canManage" [ngModel]="newDisk()" (ngModelChange)="newDisk.set(+$event)" data-testid="svc-disk" /> GB</label>
1924
+ <button type="button" class="o-btn o-dark" (click)="resize(d)" [disabled]="busy() || !d.canManage || (newPlan() === d.plan && newDisk() === d.diskGb)" data-testid="svc-resize">Resize</button>
1925
+ </div>
1926
+ <div class="o-cf"><button type="button" class="o-tog" disabled aria-label="Grow storage automatically (GA)"></button>Grow storage automatically at 85% full, up to 500 GB<b class="o-ph-tag o-ga">GA</b></div>
1927
+ </div>
1928
+ <div class="o-card"><div class="o-ch"><b>High availability</b><b class="o-ph-tag o-ga">GA</b></div><div class="o-row"><span style="flex:1">Standby node with automatic failover</span><button type="button" class="o-tog" disabled aria-label="Standby (GA)"></button></div><div class="o-row"><span style="flex:1">Read replicas</span><span class="o-faint o-sm">0</span><button type="button" class="o-btn o-xs" disabled>+ Replica</button></div></div>
1929
+ <div class="o-card">
1930
+ <div class="o-ch"><b>Networking</b></div>
1931
+ <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>
1932
+ <div class="o-row"><span style="flex:1">Public endpoint with trusted sources</span><button type="button" class="o-tog" [class.o-on]="d.publicEnabled" [disabled]="!d.canManage || busy()" (click)="save(d, { publicEnabled: !d.publicEnabled }, d.publicEnabled ? 'The public endpoint is off.' : 'The public endpoint is on for the trusted sources below.')" aria-label="Public endpoint" data-testid="svc-public"></button></div>
1933
+ @if (d.publicEnabled) {
1934
+ <div class="o-row o-wrap" style="background:var(--o-bg)">
1935
+ @for (c of d.allowedCidrs; track c) {<span class="o-chip o-mono">{{ c }}@if (d.canManage) { <button type="button" class="o-lnk o-faint" (click)="dropCidr(d, c)" [attr.aria-label]="'Remove ' + c">×</button>}</span>}
1936
+ @if (d.canManage) {<input class="o-in o-sm o-mono" style="width:180px" placeholder="+ IP or CIDR" aria-label="Add a trusted source" [ngModel]="cidr()" (ngModelChange)="cidr.set($event)" (keydown.enter)="addCidr(d)" data-testid="svc-cidr" />}
1937
+ <span class="o-faint o-sm o-r">TLS required</span>
1938
+ </div>
1939
+ }
1940
+ </div>
1941
+ <div class="o-card">
1942
+ <div class="o-ch"><b>Maintenance window</b><span class="o-r">patches with 7-day notice</span></div>
1943
+ <div class="o-cb o-flex o-wrap">
1944
+ <select class="o-in" style="width:auto" aria-label="Day" [disabled]="!d.canManage" [ngModel]="d.maintenanceDay" (ngModelChange)="save(d, { maintenanceDay: $event }, 'Maintenance window saved.')">@for (x of days; track x.id) {<option [value]="x.id">{{ x.label }}</option>}</select>
1945
+ <select class="o-in" style="width:auto" aria-label="Hours" [disabled]="!d.canManage" [ngModel]="d.maintenanceHour" (ngModelChange)="save(d, { maintenanceHour: +$event }, 'Maintenance window saved.')">@for (h of hours; track h) {<option [ngValue]="h">{{ windowHours(h) }}</option>}</select>
1946
+ <span class="o-faint o-sm">Minor versions of {{ d.engineName }} {{ d.version }} apply here.</span>
1947
+ </div>
1948
+ </div>
1949
+ <div class="o-card"><div class="o-ch"><b>Engine parameters</b><b class="o-ph-tag o-ga">GA</b><span class="o-r">validated subset</span></div><div class="o-tbl"><div class="o-tbody">
1950
+ @for (p of params(); track p[0]) {<div class="o-tr"><div class="o-td o-mono o-sm">{{ p[0] }}</div><div class="o-td"><input class="o-in o-sm o-mono" style="width:120px" [value]="p[1]" disabled [attr.aria-label]="p[0]" /></div><div class="o-td"><span class="o-chip" [class.o-amb]="p[2] === 'restart'">{{ p[2] }}</span></div></div>}
1951
+ </div></div></div>
1952
+ @if (d.canManage && !d.deletedAt) {
1953
+ <div class="o-card" style="border-color:var(--o-bad-line)"><div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>Delete {{ d.name }}</b><span class="o-hint">Typed confirmation. Undo within 24 hours; optional final backup kept 7 days. Attached apps lose {{ envVar() }}.</span></div><button type="button" class="o-btn o-sm o-red" (click)="dialog.set({ kind: 'delete', typed: '', finalBackup: true })" data-testid="svc-delete">Delete</button></div></div>
1954
+ }
1955
+ </div>
1956
+ }
1957
+ }
1958
+ </div>
1959
+
1960
+ @if (dialog(); as dl) {
1961
+ <div class="o-overlay o-mid" (click)="dialog.set(null)">
1962
+ <div class="o-dlg" role="dialog" aria-modal="true" [attr.aria-label]="dialogTitle(dl, d)" (click)="$event.stopPropagation()" (keydown.escape)="dialog.set(null)">
1963
+ <div class="o-dlg-h"><b>{{ dialogTitle(dl, d) }}</b></div>
1964
+ <div class="o-dlg-b">
1965
+ @switch (dl.kind) {
1966
+ @case ('delete') {
1967
+ <span>Attached apps lose {{ envVar() }} and redeploy. You can undo within 24 hours.</span>
1968
+ <label class="o-flex o-sm"><input type="checkbox" [(ngModel)]="dl.finalBackup" /> Take a final backup and keep it 7 days</label>
1969
+ <label class="o-fld"><span class="o-sm">Type <b class="o-mono">{{ d.name }}</b> to confirm</span><input class="o-in o-mono" [(ngModel)]="dl.typed" data-testid="svc-delete-typed" autocomplete="off" /></label>
1970
+ }
1971
+ @case ('restore') {
1972
+ <span>{{ dl.point ? 'Restores ' + d.name + ' as it was at ' + whenLocal(dl.point) : 'Restores backup ' + dl.backup }} into a new instance with the same users and passwords. {{ d.name }} keeps running untouched; move its apps over afterwards if you want.</span>
1973
+ <label class="o-fld"><span class="o-sm">New instance</span><input class="o-in o-mono" [(ngModel)]="dl.name" data-testid="svc-restore-name" autocomplete="off" /></label>
1974
+ }
1975
+ @case ('rotate') {
1976
+ <span>A new password for <b class="o-mono">{{ dl.user }}</b> is created beside the current one. Every attached app redeploys with it; once all are healthy the old one stays valid for the grace period, then stops. If one fails, the old password stays.</span>
1977
+ <label class="o-fld"><span class="o-sm">Keep the old password after the apps are healthy</span>
1978
+ <select class="o-in" [(ngModel)]="dl.grace">@for (g of graces; track g.m) {<option [ngValue]="g.m">{{ g.label }}</option>}</select></label>
1979
+ }
1980
+ }
1981
+ </div>
1982
+ <div class="o-dlg-f">
1983
+ <button type="button" class="o-btn" (click)="dialog.set(null)">Cancel</button>
1984
+ @switch (dl.kind) {
1985
+ @case ('delete') {<button type="button" class="o-btn o-red" [disabled]="busy() || dl.typed !== d.name" (click)="remove(d, dl.finalBackup)" data-testid="svc-delete-go">Delete service</button>}
1986
+ @case ('restore') {<button type="button" class="o-btn o-dark" [disabled]="busy() || !dl.name" (click)="restore(d, dl)" data-testid="svc-restore-go">Restore</button>}
1987
+ @case ('rotate') {<button type="button" class="o-btn o-dark" [disabled]="busy()" (click)="rotate(d, dl.user, dl.grace)" data-testid="svc-rotate-go">Rotate</button>}
1988
+ }
1989
+ </div>
1990
+ </div>
1991
+ </div>
1992
+ }
1993
+ } @else {
1994
+ <div class="o-page">@if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>} @else {<div class="o-empty">Loading…</div>}</div>
1995
+ }
1996
+ `,
1997
+ }]
1998
+ }], ctorParameters: () => [] });
1999
+
2000
+ /*
2001
+ * @chisquare-tech/origine-ngx-apps-services: the Services module's screens (TRD §6.8, §6.10). The
2002
+ * host app provides ServicesHost and mounts the pages at /{org}/{project}/services (the list),
2003
+ * /services/new (the wizard) and /services/:id (an instance, ?tab=users|backups|metrics|logs|console|settings).
2004
+ */
2005
+
2006
+ /**
2007
+ * Generated bundle index. Do not edit.
2008
+ */
2009
+
2010
+ export { DAYS, ENGINE_COLOUR, LIFECYCLE, PRESETS, ServiceLogsTab, ServiceMetricsTab, ServicePage, ServicesApi, ServicesHost, ServicesListPage, ServicesNewPage, apiMessage, backupKind, backupsLine, bytes, fromLocalInput, hhmm, hhmmss, money, moving, num, pct, presetChip, ratio, shortDate, spark, sparkPath, stateChip, stateDot, toLocalInput, utcHour, when };
2011
+ //# sourceMappingURL=chisquare-tech-origine-ngx-apps-services.mjs.map