metergraph-cli 0.2.0 → 0.2.2
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.
- package/README.md +21 -16
- package/assets/skill/SKILL.md +53 -4
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +16 -16
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-local-bundle/SKILL.md +4 -4
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/package.json +1 -1
- package/src/agent-key.js +157 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +373 -14
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +20 -3
- package/src/callback-page.js +58 -0
- package/src/cli.js +94 -37
- package/src/constants.js +32 -3
- package/src/environment.js +169 -0
- package/src/origin.js +6 -0
- package/src/output.js +325 -130
- package/src/read-contract.js +953 -13
- package/src/read-output.js +449 -30
- package/src/read.js +297 -59
- package/src/setup-deployment.js +4 -3
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +8 -4
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
package/src/style.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { EXIT_CODES } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
// Presentation for human (non --json) output: a status headline, aligned
|
|
4
|
+
// fields and next steps. Color is used only when the stream is a terminal
|
|
5
|
+
// and NO_COLOR is unset (https://no-color.org); FORCE_COLOR turns it on.
|
|
6
|
+
// Symbols are plain Unicode and are printed with or without color.
|
|
7
|
+
|
|
8
|
+
const CODES = { bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39], cyan: [36, 39] };
|
|
9
|
+
|
|
10
|
+
function makeStyle(color, width = Infinity) {
|
|
11
|
+
const style = { color, width };
|
|
12
|
+
for (const [name, [open, close]] of Object.entries(CODES)) {
|
|
13
|
+
style[name] = color ? (text) => `\u001b[${open}m${text}\u001b[${close}m` : (text) => String(text);
|
|
14
|
+
}
|
|
15
|
+
return style;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PLAIN = makeStyle(false);
|
|
19
|
+
|
|
20
|
+
// Prose wraps to the terminal width only on a terminal, so piped output keeps
|
|
21
|
+
// one line per message.
|
|
22
|
+
export function createStyle(stream, env = process.env) {
|
|
23
|
+
const tty = Boolean(stream?.isTTY);
|
|
24
|
+
const width = tty ? Math.min(Math.max(stream.columns || 80, 40), 100) - 2 : Infinity;
|
|
25
|
+
if (env.NO_COLOR) return makeStyle(false, width);
|
|
26
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return makeStyle(true, width);
|
|
27
|
+
return makeStyle(tty && env.TERM !== "dumb", width);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TONES = {
|
|
31
|
+
ok: ["✓", "green"],
|
|
32
|
+
warn: ["!", "yellow"],
|
|
33
|
+
fail: ["✗", "red"],
|
|
34
|
+
info: ["•", "cyan"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ok for success; warn when the command stopped for a person to act;
|
|
38
|
+
// fail for everything else.
|
|
39
|
+
export function toneFor(outcome) {
|
|
40
|
+
if (outcome === "ok") return "ok";
|
|
41
|
+
if (outcome === "action_required" || outcome === "authentication_required") return "warn";
|
|
42
|
+
return "fail";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function headline(style, tone, title, outcome = null) {
|
|
46
|
+
const [icon, color] = TONES[tone];
|
|
47
|
+
let line = `${style[color](icon)} ${style.bold(title)}`;
|
|
48
|
+
if (outcome !== null && outcome !== "ok") line += ` ${style.dim(`(${outcome}, exit ${EXIT_CODES[outcome]})`)}`;
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Aligned "label value" rows. Rows whose value is null or undefined are left out.
|
|
53
|
+
export function fields(style, rows, indent = " ") {
|
|
54
|
+
const shown = rows.filter(([, value]) => value !== null && value !== undefined);
|
|
55
|
+
const width = Math.max(0, ...shown.map(([label]) => label.length));
|
|
56
|
+
return shown.map(([label, value]) => `${indent}${style.dim(label.padEnd(width))} ${value}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Indents prose and wraps it to style.width.
|
|
60
|
+
export function paragraph(style, text, indent = " ") {
|
|
61
|
+
const width = style.width;
|
|
62
|
+
const lines = [];
|
|
63
|
+
let line = "";
|
|
64
|
+
for (const word of String(text).split(/\s+/).filter(Boolean)) {
|
|
65
|
+
if (line && indent.length + line.length + 1 + word.length > width) {
|
|
66
|
+
lines.push(indent + line);
|
|
67
|
+
line = word;
|
|
68
|
+
} else {
|
|
69
|
+
line = line ? `${line} ${word}` : word;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (line) lines.push(indent + line);
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// "→ label value" rows for what to do next, aligned like fields.
|
|
77
|
+
export function steps(style, rows) {
|
|
78
|
+
const shown = rows.filter(([, value]) => value !== null && value !== undefined);
|
|
79
|
+
const width = Math.max(0, ...shown.map(([label]) => label.length));
|
|
80
|
+
return shown.flatMap(([label, value]) => {
|
|
81
|
+
if (label) return [`${style.cyan("→")} ${label.padEnd(width)} ${value}`];
|
|
82
|
+
// Unlabeled steps are prose: wrap them with a hanging indent.
|
|
83
|
+
const [first, ...rest] = paragraph(style, value, " ");
|
|
84
|
+
return [`${style.cyan("→")} ${first.trimStart()}`, ...rest];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function section(style, title) {
|
|
89
|
+
return style.bold(title);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function render(lines) {
|
|
93
|
+
// Collapse runs of blank lines and trim blank edges.
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
if (line === "" && (out.length === 0 || out[out.length - 1] === "")) continue;
|
|
97
|
+
out.push(line);
|
|
98
|
+
}
|
|
99
|
+
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
100
|
+
return `${out.join("\n")}\n`;
|
|
101
|
+
}
|
package/src/transport.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
3
|
|
|
4
|
-
import { PACKAGE_NAME, READ_QUERY_KEYS, VERSION } from "./constants.js";
|
|
4
|
+
import { MAX_QUERY_LENGTH, PACKAGE_NAME, READ_QUERY_KEYS, READ_QUERY_REPEATABLE, VERSION } from "./constants.js";
|
|
5
5
|
import { classifyError } from "./http.js";
|
|
6
6
|
|
|
7
7
|
const USER_AGENT = `${PACKAGE_NAME}/${VERSION}`;
|
|
8
|
-
|
|
8
|
+
// MAX_QUERY_LENGTH is shared with argument validation (constants.js).
|
|
9
9
|
|
|
10
10
|
// Builds the request URL. Without a query the path must be a fixed path on
|
|
11
11
|
// the origin with no search part. A query is accepted only as a
|
|
12
12
|
// URLSearchParams the caller built, only on a path in READ_QUERY_KEYS, only
|
|
13
|
-
// with that path's keys, each at most once
|
|
13
|
+
// with that path's keys, each at most once unless READ_QUERY_REPEATABLE lists
|
|
14
|
+
// it for that path. Anything else is a programming
|
|
14
15
|
// error, so no input can choose another path, origin or header.
|
|
15
16
|
function requestUrl(origin, pathname, query) {
|
|
16
17
|
const url = new URL(pathname, origin);
|
|
@@ -23,9 +24,12 @@ function requestUrl(origin, pathname, query) {
|
|
|
23
24
|
if (!(query instanceof URLSearchParams) || allowed === null) {
|
|
24
25
|
throw new Error("a query is not allowed on this path");
|
|
25
26
|
}
|
|
27
|
+
const repeatable = READ_QUERY_REPEATABLE[pathname] ?? [];
|
|
26
28
|
const seen = new Set();
|
|
27
29
|
for (const key of query.keys()) {
|
|
28
|
-
if (!allowed.includes(key) || seen.has(key)
|
|
30
|
+
if (!allowed.includes(key) || (seen.has(key) && !repeatable.includes(key))) {
|
|
31
|
+
throw new Error("query key is not allowed");
|
|
32
|
+
}
|
|
29
33
|
seen.add(key);
|
|
30
34
|
}
|
|
31
35
|
const search = query.toString();
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { GROUP_KEY } from "./read-contract.js";
|
|
2
|
+
|
|
3
|
+
// Ranking and prior-period comparison for grouped usage. Both work on reports
|
|
4
|
+
// already validated by usageAggregatesReport and say when the returned groups
|
|
5
|
+
// are not every group, so a ranking or a change is never presented as
|
|
6
|
+
// complete when it is not.
|
|
7
|
+
|
|
8
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
9
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
10
|
+
|
|
11
|
+
// A group's value for one dimension. A metadata dimension's value is
|
|
12
|
+
// metadata_value, and the group of calls without one is told apart from an
|
|
13
|
+
// empty string.
|
|
14
|
+
const valueOf = (item, dimension) =>
|
|
15
|
+
dimension.startsWith("metadata.") ? (item.has_value ? item.metadata_value ?? "" : null) : item[dimension];
|
|
16
|
+
// Groups are joined by the raw group values the validator kept (GROUP_KEY),
|
|
17
|
+
// never by display values, which can be null for text that is unsafe to show.
|
|
18
|
+
const byKey = (groupBy) => (item) =>
|
|
19
|
+
item[GROUP_KEY] ?? JSON.stringify(groupBy.map((dimension) => valueOf(item, dimension) ?? null));
|
|
20
|
+
const desc = (pick) => (a, b) => {
|
|
21
|
+
const left = pick(a);
|
|
22
|
+
const right = pick(b);
|
|
23
|
+
// Unknown values (null) rank last, whichever direction.
|
|
24
|
+
if (left === null && right === null) return 0;
|
|
25
|
+
if (left === null) return 1;
|
|
26
|
+
if (right === null) return -1;
|
|
27
|
+
return right - left;
|
|
28
|
+
};
|
|
29
|
+
const then = (...orders) => (a, b) => {
|
|
30
|
+
for (const order of orders) {
|
|
31
|
+
const result = order(a, b);
|
|
32
|
+
if (result !== 0) return result;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The service orders groups by cost, then calls, then group key. Every other
|
|
38
|
+
// order is applied here over the returned groups.
|
|
39
|
+
const ORDERS = {
|
|
40
|
+
cost: [desc((item) => item.cost_usd), desc((item) => item.calls)],
|
|
41
|
+
"cost-per-call": [desc((item) => item.cost_per_call_usd), desc((item) => item.cost_usd)],
|
|
42
|
+
calls: [desc((item) => item.calls), desc((item) => item.cost_usd)],
|
|
43
|
+
errors: [desc((item) => item.error_calls), desc((item) => item.error_rate), desc((item) => item.calls)],
|
|
44
|
+
"avg-latency": [desc((item) => item.latency.avg_ms), desc((item) => item.calls)],
|
|
45
|
+
"p95-latency": [desc((item) => item.latency.p95_ms), desc((item) => item.calls)],
|
|
46
|
+
day: [(a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0), desc((item) => item.cost_usd)],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// What each order means, for JSON and text output.
|
|
50
|
+
export const SORT_LABELS = Object.freeze({
|
|
51
|
+
cost: "cost, highest first",
|
|
52
|
+
"cost-per-call": "cost per call (cost_usd / calls), highest first",
|
|
53
|
+
calls: "calls, most first",
|
|
54
|
+
errors: "error calls, most first",
|
|
55
|
+
"avg-latency": "mean latency over calls that reported one, highest first",
|
|
56
|
+
"p95-latency": "estimated p95 latency (fixed histogram), highest first",
|
|
57
|
+
day: "UTC day, oldest first",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Orders the report's items and numbers them. A ranking other than by cost
|
|
61
|
+
// over a cut list (the service returns the top groups by cost) is not a
|
|
62
|
+
// ranking of every group, and says so.
|
|
63
|
+
export function rankReport(report, sort, notices) {
|
|
64
|
+
const key = byKey(report.group_by);
|
|
65
|
+
const order = then(...ORDERS[sort], (a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
66
|
+
const items = [...report.items].sort(order).map((item, index) => ({ rank: index + 1, ...item }));
|
|
67
|
+
const rankingComplete = sort === "cost" || !report.truncated;
|
|
68
|
+
if (!rankingComplete) notices.add("ranking_partial");
|
|
69
|
+
return {
|
|
70
|
+
...report,
|
|
71
|
+
items,
|
|
72
|
+
sort: { by: sort, meaning: SORT_LABELS[sort], complete: rankingComplete },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The window to compare with, from the current window the service read.
|
|
77
|
+
// "previous" is the whole period before: for a preset of N UTC days, the N
|
|
78
|
+
// whole UTC days before the current UTC day's start, even though the current
|
|
79
|
+
// window ends part way through today. "previous-same-time" ends the prior
|
|
80
|
+
// window at the same elapsed time, so both windows are the same length.
|
|
81
|
+
export function priorWindow(window, mode) {
|
|
82
|
+
const since = Date.parse(window.since);
|
|
83
|
+
const until = Date.parse(window.until);
|
|
84
|
+
const length = window.preset === "utc_days" ? window.days * DAY_MS : until - since;
|
|
85
|
+
const priorSince = since - length;
|
|
86
|
+
const priorUntil = mode === "previous" ? since : priorSince + (until - since);
|
|
87
|
+
return { since: new Date(priorSince).toISOString(), until: new Date(priorUntil).toISOString() };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The service reports amounts to 8 decimals; keep that precision and take the
|
|
91
|
+
// percentage from the unrounded difference.
|
|
92
|
+
const change = (now, before) => {
|
|
93
|
+
if (now === null || before === null) return { delta: null, percent: null };
|
|
94
|
+
const raw = now - before;
|
|
95
|
+
return {
|
|
96
|
+
delta: Math.round(raw * 1e8) / 1e8,
|
|
97
|
+
percent: before === 0 ? null : Math.round((raw / before) * 1000) / 10,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
// A cost change is known only when both sides' cost is exact: a lower bound
|
|
101
|
+
// can hide a larger true cost and reverse the direction.
|
|
102
|
+
const exact = (pricing) => pricing?.cost === "exact";
|
|
103
|
+
const costChange = (now, before, nowPricing, beforePricing) =>
|
|
104
|
+
exact(nowPricing) && exact(beforePricing) ? change(now, before) : { delta: null, percent: null };
|
|
105
|
+
// A window's totals are the whole question only when its evidence and
|
|
106
|
+
// coverage are complete (rows cut at --limit do not matter for totals);
|
|
107
|
+
// otherwise unseen calls could reverse a change, so none is reported.
|
|
108
|
+
const covered = (report) =>
|
|
109
|
+
report.evidence?.complete !== false &&
|
|
110
|
+
(report.coverage === null || report.coverage === undefined || report.coverage.completeness === "complete");
|
|
111
|
+
|
|
112
|
+
const sideOf = (item) => ({ calls: item.calls, error_calls: item.error_calls, cost_usd: item.cost_usd, pricing: item.pricing });
|
|
113
|
+
|
|
114
|
+
// Joins the current and prior reports group by group. A group missing from a
|
|
115
|
+
// window whose groups are all listed had no calls there (zero); missing from
|
|
116
|
+
// a cut window it is unknown (null), never zero.
|
|
117
|
+
export function compareReports(current, prior, mode, notices) {
|
|
118
|
+
const key = byKey(current.group_by);
|
|
119
|
+
const priorByKey = new Map(prior.items.map((item) => [key(item), item]));
|
|
120
|
+
const currentKeys = new Set(current.items.map(key));
|
|
121
|
+
// No calls in a fully listed window: zero, and exactly so.
|
|
122
|
+
const zero = {
|
|
123
|
+
calls: 0, error_calls: 0, cost_usd: 0,
|
|
124
|
+
pricing: { priced_calls: 0, zero_cost_calls: 0, partial_calls: 0, unpriced_calls: 0, unknown_calls: 0, cost: "exact" },
|
|
125
|
+
};
|
|
126
|
+
// Absence proves zero only in a window whose evidence is complete (not cut,
|
|
127
|
+
// and nothing pending, clipped or unknown); otherwise it is unknown.
|
|
128
|
+
const absent = (report) => (report.complete ? zero : null);
|
|
129
|
+
const group = (item) => Object.fromEntries(current.group_by.flatMap((dimension) =>
|
|
130
|
+
dimension.startsWith("metadata.")
|
|
131
|
+
? [["metadata_key", item.metadata_key], ["metadata_value", item.metadata_value], ["has_value", item.has_value]]
|
|
132
|
+
: [[dimension, item[dimension]]]));
|
|
133
|
+
const bothCovered = covered(current) && covered(prior);
|
|
134
|
+
const unknownChange = { delta: null, percent: null };
|
|
135
|
+
const row = (item, now, before) => ({
|
|
136
|
+
...group(item),
|
|
137
|
+
current: now,
|
|
138
|
+
prior: before,
|
|
139
|
+
cost_change: bothCovered
|
|
140
|
+
? costChange(now?.cost_usd ?? null, before?.cost_usd ?? null, now?.pricing, before?.pricing)
|
|
141
|
+
: unknownChange,
|
|
142
|
+
calls_change: bothCovered ? change(now?.calls ?? null, before?.calls ?? null) : unknownChange,
|
|
143
|
+
});
|
|
144
|
+
const items = current.items.map((item) => {
|
|
145
|
+
const match = priorByKey.get(key(item));
|
|
146
|
+
return row(item, sideOf(item), match === undefined ? absent(prior) : sideOf(match));
|
|
147
|
+
});
|
|
148
|
+
const onlyPrior = prior.items
|
|
149
|
+
.filter((item) => !currentKeys.has(key(item)))
|
|
150
|
+
.map((item) => row(item, absent(current), sideOf(item)));
|
|
151
|
+
const currentHours = current.window.hours;
|
|
152
|
+
const priorHours = prior.window.hours;
|
|
153
|
+
// A window that ends part way through the current UTC day compared with a
|
|
154
|
+
// longer prior period: totals are not like for like.
|
|
155
|
+
const unequal = Math.abs(currentHours - priorHours) > 0.01;
|
|
156
|
+
const partialAgainstFull = unequal && current.window.partial_days.length > 0;
|
|
157
|
+
if (partialAgainstFull) notices.add("partial_day_comparison");
|
|
158
|
+
const complete = current.complete && prior.complete;
|
|
159
|
+
if (!complete) notices.add("comparison_incomplete");
|
|
160
|
+
const totals = (report) => ({
|
|
161
|
+
calls: report.totals.calls,
|
|
162
|
+
error_calls: report.totals.error_calls,
|
|
163
|
+
cost_usd: report.totals.cost_usd,
|
|
164
|
+
});
|
|
165
|
+
const likeForLike = partialAgainstFull ? priorWindow(current.window, "previous-same-time") : null;
|
|
166
|
+
return {
|
|
167
|
+
mode,
|
|
168
|
+
prior: {
|
|
169
|
+
window: prior.window,
|
|
170
|
+
totals: prior.totals,
|
|
171
|
+
coverage: prior.coverage,
|
|
172
|
+
pricing: prior.pricing,
|
|
173
|
+
attribution: prior.attribution,
|
|
174
|
+
evidence: prior.evidence,
|
|
175
|
+
warnings: prior.warnings,
|
|
176
|
+
truncated: prior.truncated,
|
|
177
|
+
complete: prior.complete,
|
|
178
|
+
rows: prior.rows,
|
|
179
|
+
matched_groups: prior.matched_groups,
|
|
180
|
+
},
|
|
181
|
+
hours: { current: currentHours, prior: priorHours },
|
|
182
|
+
partial_day_compared_with_full: partialAgainstFull,
|
|
183
|
+
like_for_like_window: likeForLike,
|
|
184
|
+
complete,
|
|
185
|
+
totals: {
|
|
186
|
+
current: totals(current),
|
|
187
|
+
prior: totals(prior),
|
|
188
|
+
cost_change: bothCovered
|
|
189
|
+
? costChange(current.totals.cost_usd, prior.totals.cost_usd, current.pricing, prior.pricing)
|
|
190
|
+
: unknownChange,
|
|
191
|
+
cost_change_known: bothCovered && exact(current.pricing) && exact(prior.pricing),
|
|
192
|
+
calls_change: bothCovered ? change(current.totals.calls, prior.totals.calls) : unknownChange,
|
|
193
|
+
calls_change_known: bothCovered,
|
|
194
|
+
},
|
|
195
|
+
items: [...items, ...onlyPrior],
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/verify-output.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
|
|
2
|
+
|
|
1
3
|
const MESSAGES = Object.freeze({
|
|
2
4
|
invalid_trace_identity: "Provide exactly one exact trace or request ID from your application run.",
|
|
3
5
|
invalid_invocation_window: "Provide the start and end timestamps of that application invocation.",
|
|
@@ -19,18 +21,26 @@ export function verifyMessage(outcome, reason) {
|
|
|
19
21
|
return MESSAGES[reason] ?? `Trace verification stopped (${outcome}). No application traffic claim was made.`;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export function verifyText(result) {
|
|
24
|
+
export function verifyText(result, style = PLAIN) {
|
|
23
25
|
const data = result.data;
|
|
24
|
-
const
|
|
26
|
+
const title = result.ok ? "Trace verified" : "Trace not verified";
|
|
27
|
+
const lines = [headline(style, toneFor(result.outcome), title, result.outcome), ""];
|
|
25
28
|
if (data) {
|
|
26
|
-
lines.push(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
lines.push(...fields(style, [
|
|
30
|
+
["Workspace", data.workspace.id],
|
|
31
|
+
["Deployment", data.deployment_profile],
|
|
32
|
+
["Trace ID", data.trace?.trace_id ?? null],
|
|
33
|
+
["Invocation source", data.invocation.source],
|
|
34
|
+
["Attempts", data.attempts],
|
|
35
|
+
["Processed", data.readiness.processed],
|
|
36
|
+
["Metadata available", data.readiness.metadata_available],
|
|
37
|
+
["Application traffic verified", data.application_traffic_verified],
|
|
38
|
+
["Browser", data.browser ?? null],
|
|
39
|
+
]), "");
|
|
40
|
+
}
|
|
41
|
+
if (result.error) lines.push(...paragraph(style, result.error.message), "");
|
|
42
|
+
if (data?.app_url) {
|
|
43
|
+
lines.push(...steps(style, [[data.link_workspace_bound ? "Dashboard" : "Dashboard (select the correct workspace)", data.app_url]]));
|
|
33
44
|
}
|
|
34
|
-
|
|
35
|
-
return `${lines.join("\n")}\n`;
|
|
45
|
+
return render(lines);
|
|
36
46
|
}
|