@rootly/wizard 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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/rootly-logo-glyph-purple.png +0 -0
- package/assets/rootly-logo-glyph.png +0 -0
- package/assets/welcome-screen.png +0 -0
- package/package.json +47 -0
- package/src/actions/guided.js +87 -0
- package/src/actions/inspect.js +341 -0
- package/src/actions/integrations.js +75 -0
- package/src/actions/mcp.js +52 -0
- package/src/actions/oneshot.js +243 -0
- package/src/actions/phone.js +122 -0
- package/src/actions/registry.js +373 -0
- package/src/actions/setup.js +574 -0
- package/src/actions/testing.js +141 -0
- package/src/actions/workflow.js +47 -0
- package/src/auth.js +553 -0
- package/src/cli.js +199 -0
- package/src/detect-state.js +232 -0
- package/src/format.js +43 -0
- package/src/mcp.js +254 -0
- package/src/rootly-api.js +325 -0
- package/src/runtime.js +55 -0
- package/src/tui/components/AppShell.js +68 -0
- package/src/tui/components/Banner.js +145 -0
- package/src/tui/components/BigText.js +46 -0
- package/src/tui/components/Celebration.js +47 -0
- package/src/tui/components/KeyValueList.js +22 -0
- package/src/tui/components/MenuList.js +170 -0
- package/src/tui/components/MultiSelectList.js +181 -0
- package/src/tui/components/NoticeBox.js +56 -0
- package/src/tui/components/SlideReveal.js +52 -0
- package/src/tui/index.js +2078 -0
- package/src/tui/screens/ListScreen.js +29 -0
- package/src/tui/screens/LoadFailedScreen.js +30 -0
- package/src/tui/screens/LoadingScreen.js +32 -0
- package/src/tui/screens/MainMenuScreen.js +81 -0
- package/src/tui/screens/MultiSelectScreen.js +17 -0
- package/src/tui/screens/OneShotRunnerScreen.js +186 -0
- package/src/tui/screens/OptionScreen.js +24 -0
- package/src/tui/screens/ResultScreen.js +35 -0
- package/src/tui/screens/StatusScreen.js +70 -0
- package/src/tui/screens/TextEntryScreen.js +115 -0
- package/src/tui/screens/WelcomeScreen.js +66 -0
- package/src/tui/theme.js +69 -0
- package/src/tui-legacy-bridge.js +371 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { extractTeamId, extractUserId, isServiceAccount, loadApiClient } from '../runtime.js';
|
|
2
|
+
|
|
3
|
+
function formatError(error) {
|
|
4
|
+
return error?.message?.replace(/^Rootly API request failed for [^:]+:\s*/, '') || 'unknown error';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isUsersLookupUnavailable(error) {
|
|
8
|
+
const message = error?.message || '';
|
|
9
|
+
return message.includes('/v1/users') && message.includes('404');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function createTeamAction({
|
|
13
|
+
name,
|
|
14
|
+
description = 'Created during Rootly setup',
|
|
15
|
+
memberEmails = [],
|
|
16
|
+
enableAlertsAndBroadcast = false
|
|
17
|
+
} = {}) {
|
|
18
|
+
const api = await loadApiClient();
|
|
19
|
+
|
|
20
|
+
// Seed the signed-in human as the team's first member + admin. A user-less
|
|
21
|
+
// API key authenticates as a service account, which we never seed.
|
|
22
|
+
const currentUser = await api.getCurrentUser();
|
|
23
|
+
const currentUserId = extractUserId(currentUser);
|
|
24
|
+
const selfSeed = currentUserId && !isServiceAccount(currentUser) ? [String(currentUserId)] : [];
|
|
25
|
+
|
|
26
|
+
const cleanEmails = (memberEmails || []).map((value) => String(value).trim()).filter(Boolean);
|
|
27
|
+
const matchedUsers = [];
|
|
28
|
+
let userLookupUnavailable = false;
|
|
29
|
+
for (const email of cleanEmails) {
|
|
30
|
+
try {
|
|
31
|
+
const match = await api.findUserByEmail(email);
|
|
32
|
+
if (match) {
|
|
33
|
+
matchedUsers.push(match);
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (isUsersLookupUnavailable(error)) {
|
|
37
|
+
userLookupUnavailable = true;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const memberIds = matchedUsers.map((user) => Number(user.id)).filter((id) => Number.isFinite(id));
|
|
45
|
+
|
|
46
|
+
const attributes = {
|
|
47
|
+
name,
|
|
48
|
+
description,
|
|
49
|
+
user_ids: [...selfSeed, ...memberIds],
|
|
50
|
+
admin_ids: [...selfSeed],
|
|
51
|
+
auto_add_members_when_attached: true
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (cleanEmails.length) {
|
|
55
|
+
attributes.notify_emails = cleanEmails;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (enableAlertsAndBroadcast) {
|
|
59
|
+
attributes.alerts_email_enabled = true;
|
|
60
|
+
attributes.incident_broadcast_enabled = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// OAuth sessions can't assign user_ids/admin_ids — Team/Membership/User are
|
|
64
|
+
// read-only under OAuth scopes, so a create that seeds members is denied. Fall
|
|
65
|
+
// back to a bare create so the team still gets made; schedule ownership and the
|
|
66
|
+
// on-call rotation reference the user directly, so paging still works.
|
|
67
|
+
let payload;
|
|
68
|
+
let membershipSkipped = false;
|
|
69
|
+
try {
|
|
70
|
+
payload = await api.createTeam(attributes);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const denied = /\b40[134]\b|not found or unauthorized/i.test(error?.message || '');
|
|
73
|
+
if (denied && (attributes.user_ids.length || attributes.admin_ids.length)) {
|
|
74
|
+
payload = await api.createTeam({ name, description });
|
|
75
|
+
membershipSkipped = true;
|
|
76
|
+
} else {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const teamId = extractTeamId(payload);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
summary: membershipSkipped
|
|
85
|
+
? `Created team ${name} (this sign-in can't add members; add them in the Rootly web app).`
|
|
86
|
+
: `Created team ${name}.`,
|
|
87
|
+
data: {
|
|
88
|
+
id: teamId,
|
|
89
|
+
name,
|
|
90
|
+
description,
|
|
91
|
+
memberIds: membershipSkipped ? [] : memberIds,
|
|
92
|
+
membershipSkipped,
|
|
93
|
+
userLookupUnavailable,
|
|
94
|
+
matchedUsers: matchedUsers.map((user) => ({
|
|
95
|
+
id: user.id,
|
|
96
|
+
email: user.attributes?.email || null,
|
|
97
|
+
name: user.attributes?.full_name || user.attributes?.name || null
|
|
98
|
+
}))
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function addTeamMembersAction({ teamId, emails }) {
|
|
104
|
+
const api = await loadApiClient();
|
|
105
|
+
|
|
106
|
+
// Current membership lives on the team's attributes.user_ids (integers).
|
|
107
|
+
const teamPayload = await api.getTeam(teamId);
|
|
108
|
+
const teamAttributes = teamPayload?.data?.attributes || {};
|
|
109
|
+
const existingUserIds = (Array.isArray(teamAttributes.user_ids) ? teamAttributes.user_ids : [])
|
|
110
|
+
.map((id) => String(id))
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
const existingNotifyEmails = Array.isArray(teamAttributes.notify_emails) ? teamAttributes.notify_emails : [];
|
|
113
|
+
|
|
114
|
+
const cleanEmails = (emails || []).map((value) => value.trim()).filter(Boolean);
|
|
115
|
+
const resolvedMembers = [];
|
|
116
|
+
const unresolvedEmails = [];
|
|
117
|
+
let userLookupUnavailable = false;
|
|
118
|
+
for (const email of cleanEmails) {
|
|
119
|
+
try {
|
|
120
|
+
const match = await api.findUserByEmail(email);
|
|
121
|
+
if (match) {
|
|
122
|
+
resolvedMembers.push(match);
|
|
123
|
+
} else {
|
|
124
|
+
unresolvedEmails.push(email);
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (isUsersLookupUnavailable(error)) {
|
|
128
|
+
userLookupUnavailable = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const resolvedMemberIds = resolvedMembers.map((user) => String(user.id)).filter(Boolean);
|
|
137
|
+
|
|
138
|
+
const attributes = {};
|
|
139
|
+
if (userLookupUnavailable) {
|
|
140
|
+
// No user lookup in this session — fall back to attaching every email as a contact.
|
|
141
|
+
attributes.notify_emails = [...new Set([...existingNotifyEmails, ...cleanEmails])];
|
|
142
|
+
} else {
|
|
143
|
+
// Resolved people become real team members; only unmatched emails stay as contacts.
|
|
144
|
+
attributes.user_ids = [...new Set([...existingUserIds, ...resolvedMemberIds])];
|
|
145
|
+
if (unresolvedEmails.length) {
|
|
146
|
+
attributes.notify_emails = [...new Set([...existingNotifyEmails, ...unresolvedEmails])];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The PUT response echoes attributes.user_ids, so read membership back to confirm.
|
|
151
|
+
const updatePayload = await api.updateTeam(teamId, attributes);
|
|
152
|
+
const updatedAttributes = updatePayload?.data?.attributes || {};
|
|
153
|
+
const memberUserIds = (Array.isArray(updatedAttributes.user_ids) ? updatedAttributes.user_ids : existingUserIds)
|
|
154
|
+
.map((id) => String(id))
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
const addedMemberIds = resolvedMemberIds.filter((id) => !existingUserIds.includes(id));
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
summary: userLookupUnavailable
|
|
161
|
+
? `Attached ${cleanEmails.length} email(s) to team ${teamId} as contacts (could not resolve Rootly users with this auth session).`
|
|
162
|
+
: `Added ${addedMemberIds.length} member(s) to team ${teamId}.`,
|
|
163
|
+
data: {
|
|
164
|
+
teamId,
|
|
165
|
+
requestedEmails: cleanEmails,
|
|
166
|
+
userLookupUnavailable,
|
|
167
|
+
addedMemberIds,
|
|
168
|
+
memberUserIds,
|
|
169
|
+
unresolvedEmails,
|
|
170
|
+
matchedUsers: resolvedMembers.map((user) => ({
|
|
171
|
+
id: String(user.id),
|
|
172
|
+
email: user.attributes?.email || null,
|
|
173
|
+
name: user.attributes?.full_name || user.attributes?.name || null
|
|
174
|
+
}))
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function addTeamMembersByIdsAction({ teamId, userIds = [] } = {}) {
|
|
180
|
+
const api = await loadApiClient();
|
|
181
|
+
|
|
182
|
+
const teamPayload = await api.getTeam(teamId);
|
|
183
|
+
const existingUserIds = (Array.isArray(teamPayload?.data?.attributes?.user_ids)
|
|
184
|
+
? teamPayload.data.attributes.user_ids
|
|
185
|
+
: []).map((id) => String(id)).filter(Boolean);
|
|
186
|
+
|
|
187
|
+
const selectedIds = (userIds || []).map((id) => String(id)).filter(Boolean);
|
|
188
|
+
const addedMemberIds = selectedIds.filter((id) => !existingUserIds.includes(id));
|
|
189
|
+
const nextUserIds = [...new Set([...existingUserIds, ...selectedIds])];
|
|
190
|
+
|
|
191
|
+
const updatePayload = await api.updateTeam(teamId, { user_ids: nextUserIds });
|
|
192
|
+
const memberUserIds = (Array.isArray(updatePayload?.data?.attributes?.user_ids)
|
|
193
|
+
? updatePayload.data.attributes.user_ids
|
|
194
|
+
: nextUserIds).map((id) => String(id)).filter(Boolean);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
summary: `Added ${addedMemberIds.length} member(s) to team ${teamId}.`,
|
|
199
|
+
data: { teamId, addedMemberIds, memberUserIds }
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function createScheduleAction({ teamId, name, handoffTime = '09:00', memberIds = [], reuseByName = false } = {}) {
|
|
204
|
+
const api = await loadApiClient();
|
|
205
|
+
|
|
206
|
+
if (reuseByName) {
|
|
207
|
+
try {
|
|
208
|
+
const existing = findByName((await api.listSchedules())?.data, name, teamId, 'owner_group_ids');
|
|
209
|
+
if (existing) {
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
summary: `Reused existing schedule ${name}.`,
|
|
213
|
+
data: { teamId, scheduleId: existing.id, name, handoffTime, rotationCreated: false, reused: true }
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// If the lookup fails, fall through and create a fresh one.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const currentUser = await api.getCurrentUser();
|
|
222
|
+
const currentUserId = extractUserId(currentUser);
|
|
223
|
+
|
|
224
|
+
const selfId = currentUserId && !isServiceAccount(currentUser) ? currentUserId : null;
|
|
225
|
+
|
|
226
|
+
// Rootly requires a schedule owner (a user-less API key is rejected without
|
|
227
|
+
// one). Prefer a real human — the signed-in user, else the first invited
|
|
228
|
+
// member — and only fall back to the current identity when none exists.
|
|
229
|
+
const ownerUserId = selfId || memberIds[0] || currentUserId;
|
|
230
|
+
|
|
231
|
+
const scheduleAttributes = {
|
|
232
|
+
name,
|
|
233
|
+
description: `Primary schedule for ${teamId}`,
|
|
234
|
+
all_time_coverage: true,
|
|
235
|
+
owner_group_ids: [teamId],
|
|
236
|
+
owner_user_id: ownerUserId
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const createdSchedule = await api.createSchedule(scheduleAttributes);
|
|
240
|
+
const scheduleId = createdSchedule?.data?.id || createdSchedule?.id || null;
|
|
241
|
+
|
|
242
|
+
// The rotation is exactly the members the caller chose; fall back to the
|
|
243
|
+
// signed-in user only when none were given. The service account is never
|
|
244
|
+
// placed on the rotation, and an empty rotation is skipped entirely.
|
|
245
|
+
const explicitMembers = (memberIds || []).map((id) => String(id)).filter(Boolean);
|
|
246
|
+
const rotationMemberIds = explicitMembers.length
|
|
247
|
+
? [...new Set(explicitMembers)]
|
|
248
|
+
: (selfId ? [String(selfId)] : []);
|
|
249
|
+
let rotationCreated = false;
|
|
250
|
+
if (scheduleId && rotationMemberIds.length) {
|
|
251
|
+
await api.createScheduleRotation(scheduleId, {
|
|
252
|
+
name: `${name} Primary Rotation`,
|
|
253
|
+
schedule_rotationable_type: 'ScheduleDailyRotation',
|
|
254
|
+
schedule_rotationable_attributes: { handoff_time: handoffTime },
|
|
255
|
+
position: 1,
|
|
256
|
+
active_all_week: true,
|
|
257
|
+
time_zone: 'Etc/UTC',
|
|
258
|
+
schedule_rotation_members: rotationMemberIds.map((id, index) => ({
|
|
259
|
+
member_type: 'User',
|
|
260
|
+
member_id: id,
|
|
261
|
+
position: index + 1
|
|
262
|
+
}))
|
|
263
|
+
});
|
|
264
|
+
rotationCreated = true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
summary: `Created schedule ${name}.`,
|
|
270
|
+
data: {
|
|
271
|
+
teamId,
|
|
272
|
+
scheduleId,
|
|
273
|
+
name,
|
|
274
|
+
handoffTime,
|
|
275
|
+
rotationCreated
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Find an existing resource for this team by name (case-insensitive). Used to
|
|
281
|
+
// keep re-runs idempotent instead of piling up duplicates. `groupKey` names the
|
|
282
|
+
// attribute that holds the owning team ids (varies by resource).
|
|
283
|
+
function findByName(list, name, teamId, groupKey) {
|
|
284
|
+
const target = String(name || '').trim().toLowerCase();
|
|
285
|
+
if (!target) return null;
|
|
286
|
+
return (list || []).find((item) => {
|
|
287
|
+
const attrs = item?.attributes || {};
|
|
288
|
+
if (String(attrs.name || '').trim().toLowerCase() !== target) return false;
|
|
289
|
+
if (!teamId) return true;
|
|
290
|
+
const groups = attrs[groupKey];
|
|
291
|
+
// If the resource exposes its owning teams, require a match; otherwise fall
|
|
292
|
+
// back to a name-only match.
|
|
293
|
+
return !Array.isArray(groups) || groups.map(String).includes(String(teamId));
|
|
294
|
+
}) || null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function createEscalationPolicyAction({ teamId, name, repeatCount = 1, createDefaultPath = false, scheduleId = null, reuseByName = false } = {}) {
|
|
298
|
+
const api = await loadApiClient();
|
|
299
|
+
|
|
300
|
+
if (reuseByName) {
|
|
301
|
+
try {
|
|
302
|
+
const existing = findByName((await api.listEscalationPolicies())?.data, name, teamId, 'group_ids');
|
|
303
|
+
if (existing) {
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
summary: `Reused existing escalation policy ${name}.`,
|
|
307
|
+
data: { id: existing.id, teamId, name, repeatCount, reused: true, pathCreated: false, pathError: null, levelCreated: false, levelError: null }
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// If the lookup fails, fall through and create a fresh one.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const payload = await api.createEscalationPolicy({
|
|
316
|
+
name,
|
|
317
|
+
description: `Default escalation policy for ${teamId}`,
|
|
318
|
+
repeat_count: repeatCount,
|
|
319
|
+
group_ids: [teamId],
|
|
320
|
+
service_ids: []
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const policyId = payload?.data?.id || payload?.id || null;
|
|
324
|
+
|
|
325
|
+
// The default path is best-effort: a path failure must not discard the
|
|
326
|
+
// already-created policy (which would orphan it), so it is caught here.
|
|
327
|
+
let pathCreated = false;
|
|
328
|
+
let pathError = null;
|
|
329
|
+
let pathId = null;
|
|
330
|
+
if (createDefaultPath && policyId) {
|
|
331
|
+
try {
|
|
332
|
+
const pathPayload = await api.createEscalationPath(policyId, {
|
|
333
|
+
name: `${name} Path`,
|
|
334
|
+
notification_type: 'audible',
|
|
335
|
+
path_type: 'escalation',
|
|
336
|
+
default: true,
|
|
337
|
+
match_mode: 'match-all-rules',
|
|
338
|
+
position: 1,
|
|
339
|
+
repeat: false,
|
|
340
|
+
initial_delay: 0,
|
|
341
|
+
rules: []
|
|
342
|
+
});
|
|
343
|
+
pathId = pathPayload?.data?.id || null;
|
|
344
|
+
pathCreated = true;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
pathError = formatError(error);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Add a level that pages the on-call person from the schedule, so a triggered
|
|
351
|
+
// alert actually reaches someone (audible → their call/SMS notification rules).
|
|
352
|
+
let levelCreated = false;
|
|
353
|
+
let levelError = null;
|
|
354
|
+
if (scheduleId && policyId) {
|
|
355
|
+
try {
|
|
356
|
+
await api.createEscalationLevel(policyId, {
|
|
357
|
+
position: 1,
|
|
358
|
+
...(pathId ? { escalation_policy_path_id: pathId } : {}),
|
|
359
|
+
notification_target_params: [{ id: scheduleId, type: 'schedule' }],
|
|
360
|
+
paging_strategy_configuration_schedule_strategy: 'on_call_only'
|
|
361
|
+
});
|
|
362
|
+
levelCreated = true;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
levelError = formatError(error);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
summary: `Created escalation policy ${name}.`,
|
|
371
|
+
data: {
|
|
372
|
+
id: policyId,
|
|
373
|
+
teamId,
|
|
374
|
+
name,
|
|
375
|
+
repeatCount,
|
|
376
|
+
pathCreated,
|
|
377
|
+
pathError,
|
|
378
|
+
levelCreated,
|
|
379
|
+
levelError
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function createAlertSourceAction({ teamId, name = 'Generic webhook', sourceType = 'generic_webhook', reuseByName = false }) {
|
|
385
|
+
const api = await loadApiClient();
|
|
386
|
+
|
|
387
|
+
if (reuseByName) {
|
|
388
|
+
try {
|
|
389
|
+
const existing = findByName((await api.listAlertSources())?.data, name, teamId, 'owner_group_ids');
|
|
390
|
+
if (existing) {
|
|
391
|
+
const attrs = existing.attributes || {};
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
summary: `Reused existing alert source ${name}.`,
|
|
395
|
+
data: {
|
|
396
|
+
id: existing.id,
|
|
397
|
+
name,
|
|
398
|
+
teamId: teamId || null,
|
|
399
|
+
reused: true,
|
|
400
|
+
webhookEndpoint: attrs.webhook_endpoint || null,
|
|
401
|
+
secret: attrs.secret || null
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
// If the lookup fails, fall through and create a fresh one.
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const payload = await api.createAlertSource({
|
|
411
|
+
name,
|
|
412
|
+
source_type: sourceType,
|
|
413
|
+
owner_group_ids: teamId ? [teamId] : [],
|
|
414
|
+
sourceable_attributes: {
|
|
415
|
+
auto_resolve: false
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const attributes = payload?.data?.attributes || {};
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
ok: true,
|
|
423
|
+
summary: `Created alert source ${name}.`,
|
|
424
|
+
data: {
|
|
425
|
+
id: payload?.data?.id || null,
|
|
426
|
+
name,
|
|
427
|
+
teamId: teamId || null,
|
|
428
|
+
webhookEndpoint: attributes.webhook_endpoint || null,
|
|
429
|
+
secret: attributes.secret || null
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function createStatusPageAction({
|
|
435
|
+
title,
|
|
436
|
+
description,
|
|
437
|
+
isPublic = false,
|
|
438
|
+
// Authentication: 'none' (default) or 'password' (needs authenticationPassword).
|
|
439
|
+
authenticationMethod = 'none',
|
|
440
|
+
authenticationPassword = null,
|
|
441
|
+
// Components to show on the page.
|
|
442
|
+
serviceIds = [],
|
|
443
|
+
functionalityIds = [],
|
|
444
|
+
// publish=true creates it live; publish=false leaves it as an unpublished draft.
|
|
445
|
+
publish = true,
|
|
446
|
+
// Optional "customize further" fields.
|
|
447
|
+
websiteUrl = null
|
|
448
|
+
} = {}) {
|
|
449
|
+
const clean = String(title || '').trim();
|
|
450
|
+
if (!clean) {
|
|
451
|
+
return { ok: false, summary: 'A status page title is required.' };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The description is internal to Rootly — it tells other admins what the page
|
|
455
|
+
// is for. Default to a clear note when the caller doesn't supply one.
|
|
456
|
+
const cleanDescription = String(description ?? '').trim()
|
|
457
|
+
|| 'Created by the Rootly setup wizard.';
|
|
458
|
+
|
|
459
|
+
const cleanServiceIds = serviceIds.map((id) => String(id)).filter(Boolean);
|
|
460
|
+
const cleanFunctionalityIds = functionalityIds.map((id) => String(id)).filter(Boolean);
|
|
461
|
+
const usePassword = authenticationMethod === 'password' && authenticationPassword;
|
|
462
|
+
|
|
463
|
+
const api = await loadApiClient();
|
|
464
|
+
const payload = await api.createStatusPage({
|
|
465
|
+
title: clean,
|
|
466
|
+
description: cleanDescription,
|
|
467
|
+
public: Boolean(isPublic),
|
|
468
|
+
enabled: Boolean(publish), // false = unpublished draft
|
|
469
|
+
authentication_method: usePassword ? 'password' : 'none',
|
|
470
|
+
...(usePassword ? { authentication_password: authenticationPassword } : {}),
|
|
471
|
+
...(cleanServiceIds.length ? { service_ids: cleanServiceIds } : {}),
|
|
472
|
+
...(cleanFunctionalityIds.length ? { functionality_ids: cleanFunctionalityIds } : {}),
|
|
473
|
+
...(websiteUrl ? { website_url: String(websiteUrl).trim() } : {})
|
|
474
|
+
});
|
|
475
|
+
const attributes = payload?.data?.attributes || {};
|
|
476
|
+
const published = Boolean(attributes.enabled);
|
|
477
|
+
const visibility = isPublic ? 'public' : 'internal';
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
summary: published
|
|
482
|
+
? `Published ${visibility} status page ${clean}.`
|
|
483
|
+
: `Saved ${visibility} status page ${clean} as a draft.`,
|
|
484
|
+
data: {
|
|
485
|
+
id: payload?.data?.id || null,
|
|
486
|
+
title: clean,
|
|
487
|
+
public: Boolean(attributes.public),
|
|
488
|
+
published,
|
|
489
|
+
authenticationMethod: usePassword ? 'password' : 'none',
|
|
490
|
+
componentCount: cleanServiceIds.length + cleanFunctionalityIds.length,
|
|
491
|
+
slug: attributes.slug || null
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function getStatusPagesAction() {
|
|
497
|
+
const api = await loadApiClient();
|
|
498
|
+
const payload = await api.listStatusPages();
|
|
499
|
+
const pages = (payload?.data || []).map((p) => ({
|
|
500
|
+
id: p.id,
|
|
501
|
+
title: p.attributes?.title || p.attributes?.public_title || '(untitled)',
|
|
502
|
+
public: Boolean(p.attributes?.public),
|
|
503
|
+
published: Boolean(p.attributes?.enabled),
|
|
504
|
+
slug: p.attributes?.slug || null,
|
|
505
|
+
// Components already attached, so the wizard can show them and not drop
|
|
506
|
+
// them when re-publishing (service_ids/functionality_ids replace the set).
|
|
507
|
+
serviceIds: (p.attributes?.service_ids || []).map((id) => String(id)),
|
|
508
|
+
functionalityIds: (p.attributes?.functionality_ids || []).map((id) => String(id))
|
|
509
|
+
}));
|
|
510
|
+
return { ok: true, data: { pages } };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Partial update — only the keys present in `input` are sent, so editing one
|
|
514
|
+
// field never resets the others.
|
|
515
|
+
export async function updateStatusPageAction(input = {}) {
|
|
516
|
+
const { id } = input;
|
|
517
|
+
if (!id) {
|
|
518
|
+
return { ok: false, summary: 'A status page id is required to update.' };
|
|
519
|
+
}
|
|
520
|
+
const attrs = {};
|
|
521
|
+
if ('title' in input && input.title != null) attrs.title = String(input.title).trim();
|
|
522
|
+
if ('authenticationMethod' in input) {
|
|
523
|
+
const usePassword = input.authenticationMethod === 'password' && input.authenticationPassword;
|
|
524
|
+
attrs.authentication_method = usePassword ? 'password' : 'none';
|
|
525
|
+
if (usePassword) attrs.authentication_password = input.authenticationPassword;
|
|
526
|
+
}
|
|
527
|
+
if ('serviceIds' in input) attrs.service_ids = (input.serviceIds || []).map((x) => String(x));
|
|
528
|
+
if ('functionalityIds' in input) attrs.functionality_ids = (input.functionalityIds || []).map((x) => String(x));
|
|
529
|
+
if ('websiteUrl' in input) attrs.website_url = input.websiteUrl ? String(input.websiteUrl).trim() : '';
|
|
530
|
+
if ('publish' in input) attrs.enabled = Boolean(input.publish);
|
|
531
|
+
|
|
532
|
+
const api = await loadApiClient();
|
|
533
|
+
const payload = await api.updateStatusPage(id, attrs);
|
|
534
|
+
const a = payload?.data?.attributes || {};
|
|
535
|
+
return {
|
|
536
|
+
ok: true,
|
|
537
|
+
summary: 'Status page updated.',
|
|
538
|
+
data: {
|
|
539
|
+
id,
|
|
540
|
+
title: a.title || null,
|
|
541
|
+
public: Boolean(a.public),
|
|
542
|
+
published: Boolean(a.enabled),
|
|
543
|
+
slug: a.slug || null
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export async function publishStatusPageAction({ id } = {}) {
|
|
549
|
+
if (!id) {
|
|
550
|
+
return { ok: false, summary: 'A status page id is required to publish.' };
|
|
551
|
+
}
|
|
552
|
+
const api = await loadApiClient();
|
|
553
|
+
const payload = await api.updateStatusPage(id, { enabled: true });
|
|
554
|
+
const attributes = payload?.data?.attributes || {};
|
|
555
|
+
return {
|
|
556
|
+
ok: true,
|
|
557
|
+
summary: `Published status page ${attributes.title || id}.`,
|
|
558
|
+
data: {
|
|
559
|
+
id,
|
|
560
|
+
title: attributes.title || null,
|
|
561
|
+
public: Boolean(attributes.public),
|
|
562
|
+
published: Boolean(attributes.enabled),
|
|
563
|
+
slug: attributes.slug || null
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function serializeActionError(error, fallbackSummary) {
|
|
569
|
+
return {
|
|
570
|
+
ok: false,
|
|
571
|
+
summary: fallbackSummary,
|
|
572
|
+
error: formatError(error)
|
|
573
|
+
};
|
|
574
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { loadApiClient } from '../runtime.js';
|
|
2
|
+
|
|
3
|
+
function cleanIds(values = []) {
|
|
4
|
+
return values.map((value) => String(value).trim()).filter(Boolean);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function createTestAlertAction({
|
|
8
|
+
summary,
|
|
9
|
+
description = '',
|
|
10
|
+
groupIds = [],
|
|
11
|
+
serviceIds = [],
|
|
12
|
+
environmentIds = [],
|
|
13
|
+
// When a notification target is given (e.g. an escalation policy), the alert
|
|
14
|
+
// is triggered against it so it actually pages the on-call person.
|
|
15
|
+
notificationTargetType = null,
|
|
16
|
+
notificationTargetId = null,
|
|
17
|
+
urgencyId = null,
|
|
18
|
+
source = 'api',
|
|
19
|
+
// When true (and no explicit target is given), resolve the team's escalation
|
|
20
|
+
// policy + High urgency and page it — so a standalone test alert reaches the
|
|
21
|
+
// on-call person, like Quick start does.
|
|
22
|
+
page = false
|
|
23
|
+
} = {}) {
|
|
24
|
+
const api = await loadApiClient();
|
|
25
|
+
const attributes = {
|
|
26
|
+
summary,
|
|
27
|
+
source
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const cleanedDescription = String(description || '').trim();
|
|
31
|
+
const cleanedGroupIds = cleanIds(groupIds);
|
|
32
|
+
const cleanedServiceIds = cleanIds(serviceIds);
|
|
33
|
+
const cleanedEnvironmentIds = cleanIds(environmentIds);
|
|
34
|
+
|
|
35
|
+
if (page && !notificationTargetType && cleanedGroupIds.length) {
|
|
36
|
+
try {
|
|
37
|
+
const policies = (await api.listEscalationPolicies())?.data || [];
|
|
38
|
+
const teamId = cleanedGroupIds[0];
|
|
39
|
+
const policy = policies.find((p) => {
|
|
40
|
+
const g = p?.attributes?.group_ids;
|
|
41
|
+
return Array.isArray(g) && g.map(String).includes(teamId);
|
|
42
|
+
}) || (policies.length === 1 ? policies[0] : null);
|
|
43
|
+
if (policy) {
|
|
44
|
+
notificationTargetType = 'EscalationPolicy';
|
|
45
|
+
notificationTargetId = policy.id;
|
|
46
|
+
if (!urgencyId) {
|
|
47
|
+
const urgencies = (await api.listAlertUrgencies())?.data || [];
|
|
48
|
+
urgencyId = (urgencies.find((u) => /high/i.test(u?.attributes?.name || '')) || urgencies[0])?.id || null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// best-effort; fall back to a passive (non-paging) alert.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (cleanedDescription) {
|
|
57
|
+
attributes.description = cleanedDescription;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (cleanedGroupIds.length) {
|
|
61
|
+
attributes.group_ids = cleanedGroupIds;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (cleanedServiceIds.length) {
|
|
65
|
+
attributes.service_ids = cleanedServiceIds;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (cleanedEnvironmentIds.length) {
|
|
69
|
+
attributes.environment_ids = cleanedEnvironmentIds;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Page a target (escalation policy / schedule / user / group) so the alert
|
|
73
|
+
// escalates to whoever is on call and notifies them.
|
|
74
|
+
const paged = Boolean(notificationTargetType && notificationTargetId);
|
|
75
|
+
if (paged) {
|
|
76
|
+
attributes.notification_target_type = notificationTargetType;
|
|
77
|
+
attributes.notification_target_id = String(notificationTargetId);
|
|
78
|
+
attributes.status = 'triggered';
|
|
79
|
+
}
|
|
80
|
+
if (urgencyId) {
|
|
81
|
+
attributes.alert_urgency_id = String(urgencyId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const payload = await api.createAlert(attributes);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
summary: `Created test alert ${summary}.`,
|
|
89
|
+
data: {
|
|
90
|
+
id: payload?.data?.id || null,
|
|
91
|
+
summary,
|
|
92
|
+
description: cleanedDescription,
|
|
93
|
+
paged,
|
|
94
|
+
notificationTargetType: paged ? notificationTargetType : null
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function createTestIncidentAction({
|
|
100
|
+
title,
|
|
101
|
+
summary = '',
|
|
102
|
+
groupIds = [],
|
|
103
|
+
serviceIds = [],
|
|
104
|
+
environmentIds = [],
|
|
105
|
+
incidentTypeIds = [],
|
|
106
|
+
severityId = null,
|
|
107
|
+
isPrivate = false
|
|
108
|
+
} = {}) {
|
|
109
|
+
const api = await loadApiClient();
|
|
110
|
+
|
|
111
|
+
const attributes = {
|
|
112
|
+
title,
|
|
113
|
+
kind: 'normal',
|
|
114
|
+
private: isPrivate,
|
|
115
|
+
summary,
|
|
116
|
+
group_ids: cleanIds(groupIds),
|
|
117
|
+
service_ids: cleanIds(serviceIds),
|
|
118
|
+
environment_ids: cleanIds(environmentIds),
|
|
119
|
+
incident_type_ids: cleanIds(incidentTypeIds)
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (severityId) {
|
|
123
|
+
attributes.severity_id = String(severityId).trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const payload = await api.createIncident(attributes);
|
|
127
|
+
const responseAttributes = payload?.data?.attributes || {};
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
summary: `Created test incident ${title}.`,
|
|
132
|
+
data: {
|
|
133
|
+
id: payload?.data?.id || null,
|
|
134
|
+
title,
|
|
135
|
+
summary,
|
|
136
|
+
slackChannelName: responseAttributes.slack_channel_name || null,
|
|
137
|
+
slackChannelUrl: responseAttributes.slack_channel_url || null,
|
|
138
|
+
slackDeepLink: responseAttributes.slack_channel_deep_link || null
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|