@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
package/src/tui/index.js
ADDED
|
@@ -0,0 +1,2078 @@
|
|
|
1
|
+
import { createElement as h, useEffect, useState } from 'react';
|
|
2
|
+
import { render, useApp, Box } from 'ink';
|
|
3
|
+
import { palette } from './theme.js';
|
|
4
|
+
import { friendlyError, formatPhone, hyperlink } from '../format.js';
|
|
5
|
+
import { BigText } from './components/BigText.js';
|
|
6
|
+
import { WelcomeScreen } from './screens/WelcomeScreen.js';
|
|
7
|
+
import { MainMenuScreen } from './screens/MainMenuScreen.js';
|
|
8
|
+
import { StatusScreen } from './screens/StatusScreen.js';
|
|
9
|
+
import { ListScreen } from './screens/ListScreen.js';
|
|
10
|
+
import { OptionScreen } from './screens/OptionScreen.js';
|
|
11
|
+
import { LoadingScreen } from './screens/LoadingScreen.js';
|
|
12
|
+
import { LoadFailedScreen } from './screens/LoadFailedScreen.js';
|
|
13
|
+
import { TextEntryScreen } from './screens/TextEntryScreen.js';
|
|
14
|
+
import { ResultScreen } from './screens/ResultScreen.js';
|
|
15
|
+
import { MultiSelectScreen } from './screens/MultiSelectScreen.js';
|
|
16
|
+
import { OneShotRunnerScreen } from './screens/OneShotRunnerScreen.js';
|
|
17
|
+
import { Celebration } from './components/Celebration.js';
|
|
18
|
+
import {
|
|
19
|
+
loadAuthContextForTui,
|
|
20
|
+
loadOnboardingStateInteractive,
|
|
21
|
+
loadTeamsForTui,
|
|
22
|
+
loadSchedulesForTui,
|
|
23
|
+
loadEscalationPoliciesForTui,
|
|
24
|
+
authenticateWithApiTokenForTui,
|
|
25
|
+
authenticateWithBrowserForTui,
|
|
26
|
+
createTeamForTui,
|
|
27
|
+
addTeamMembersForTui,
|
|
28
|
+
addTeamMembersByIdsForTui,
|
|
29
|
+
loadAddableUsersForTui,
|
|
30
|
+
loadDirectoryUsersForTui,
|
|
31
|
+
createScheduleForTui,
|
|
32
|
+
createEscalationPolicyForTui,
|
|
33
|
+
createAlertSourceForTui,
|
|
34
|
+
createStatusPageForTui,
|
|
35
|
+
loadStatusPageComponentsForTui,
|
|
36
|
+
createCustomComponentForTui,
|
|
37
|
+
loadStatusPagesForTui,
|
|
38
|
+
publishStatusPageForTui,
|
|
39
|
+
updateStatusPageForTui,
|
|
40
|
+
createTestAlertForTui,
|
|
41
|
+
createTestIncidentForTui,
|
|
42
|
+
deleteTokenForTui,
|
|
43
|
+
runOneShotSetupForTui,
|
|
44
|
+
startPhoneVerificationForTui,
|
|
45
|
+
confirmPhoneVerificationForTui,
|
|
46
|
+
loadCurrentUserPhoneForTui,
|
|
47
|
+
startWebHandoffForTui,
|
|
48
|
+
openExternalUrlForTui,
|
|
49
|
+
previewMcpForTui,
|
|
50
|
+
applyMcpForTui,
|
|
51
|
+
loadTeamMembersForTui
|
|
52
|
+
} from '../tui-legacy-bridge.js';
|
|
53
|
+
|
|
54
|
+
const ENTER_ALT_SCREEN = '\u001b[?1049h';
|
|
55
|
+
const LEAVE_ALT_SCREEN = '\u001b[?1049l';
|
|
56
|
+
const HIDE_CURSOR = '\u001b[?25l';
|
|
57
|
+
const SHOW_CURSOR = '\u001b[?25h';
|
|
58
|
+
const CLEAR_SCREEN = '\u001b[2J';
|
|
59
|
+
const CLEAR_SCROLLBACK = '\u001b[3J';
|
|
60
|
+
const CURSOR_HOME = '\u001b[H';
|
|
61
|
+
|
|
62
|
+
const CEO_CAL_URL = 'https://cal.link/jj';
|
|
63
|
+
const SALES_DEMO_URL = 'https://rootly.com/demo?utm_term=rootly&utm_campaign=Rootly_Brand_Search_USA&utm_source=google&utm_medium=ppc&utm_content=featuresad&gad_source=1&gad_campaignid=22513408256&gbraid=0AAAAACV5CgFej7JQ4Q1NvqRnqhOLGHiP8';
|
|
64
|
+
const APP_BASE_URL = (process.env.ROOTLY_APP_URL?.trim().replace(/\/$/, '')) || 'https://rootly.com';
|
|
65
|
+
const SIGNUP_URL = `${APP_BASE_URL}/users/sign_up`;
|
|
66
|
+
const STATUS_PAGES_URL = `${APP_BASE_URL}/account/status-pages`;
|
|
67
|
+
|
|
68
|
+
function enterAltScreen() {
|
|
69
|
+
if (!process.stdout.isTTY) return;
|
|
70
|
+
process.stdout.write(`${CLEAR_SCROLLBACK}${ENTER_ALT_SCREEN}${HIDE_CURSOR}${CLEAR_SCREEN}${CURSOR_HOME}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function leaveAltScreen() {
|
|
74
|
+
if (!process.stdout.isTTY) return;
|
|
75
|
+
process.stdout.write(`${SHOW_CURSOR}${LEAVE_ALT_SCREEN}${CLEAR_SCROLLBACK}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function InkWizardApp({ onExit }) {
|
|
79
|
+
const { exit } = useApp();
|
|
80
|
+
const [screen, setScreen] = useState('welcome');
|
|
81
|
+
const [state, setState] = useState(null);
|
|
82
|
+
const [teamsData, setTeamsData] = useState(null);
|
|
83
|
+
const [schedulesData, setSchedulesData] = useState(null);
|
|
84
|
+
const [policiesData, setPoliciesData] = useState(null);
|
|
85
|
+
const [authContext, setAuthContext] = useState(null);
|
|
86
|
+
const [loading, setLoading] = useState(false);
|
|
87
|
+
const [resultScreen, setResultScreen] = useState(null);
|
|
88
|
+
const [selectedTeam, setSelectedTeam] = useState(null);
|
|
89
|
+
const [formState, setFormState] = useState({});
|
|
90
|
+
const [mcpPreview, setMcpPreview] = useState(null);
|
|
91
|
+
const [teamMembersData, setTeamMembersData] = useState(null);
|
|
92
|
+
const [addableUsers, setAddableUsers] = useState(null);
|
|
93
|
+
const [directoryUsers, setDirectoryUsers] = useState(null);
|
|
94
|
+
const [spComponents, setSpComponents] = useState(null);
|
|
95
|
+
const [spExisting, setSpExisting] = useState(null);
|
|
96
|
+
const [userPhone, setUserPhone] = useState(null);
|
|
97
|
+
const [authRecovery, setAuthRecovery] = useState(null);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
let cancelled = false;
|
|
101
|
+
if (!['menu', 'status', 'inspect', 'teams', 'schedules', 'policies', 'schedule-members', 'add-members-picker', 'one-shot-members'].includes(screen)) return undefined;
|
|
102
|
+
|
|
103
|
+
void (async () => {
|
|
104
|
+
// Reuse cached values across navigation so returning to the menu is
|
|
105
|
+
// instant. The keychain check and the workspace sweep only run when their
|
|
106
|
+
// cache is empty; mutations clear the cache (see clearWorkspaceCache),
|
|
107
|
+
// which forces a fresh load on the next visit.
|
|
108
|
+
let nextAuth = authContext;
|
|
109
|
+
if (!nextAuth) {
|
|
110
|
+
setLoading(true);
|
|
111
|
+
nextAuth = await loadAuthContextForTui();
|
|
112
|
+
if (cancelled) return;
|
|
113
|
+
setAuthContext(nextAuth);
|
|
114
|
+
}
|
|
115
|
+
if (!nextAuth?.hasAuth) {
|
|
116
|
+
if (!cancelled) {
|
|
117
|
+
setLoading(false);
|
|
118
|
+
setScreen('auth-method');
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!state) {
|
|
124
|
+
setLoading(true);
|
|
125
|
+
const stateResult = await loadOnboardingStateInteractive();
|
|
126
|
+
if (cancelled) return;
|
|
127
|
+
if (!stateResult.ok) {
|
|
128
|
+
setLoading(false);
|
|
129
|
+
if (stateResult.reason === 'auth-capability') {
|
|
130
|
+
setAuthRecovery(stateResult);
|
|
131
|
+
setScreen('auth-recovery');
|
|
132
|
+
} else {
|
|
133
|
+
setScreen('load-failed');
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setState(stateResult.state);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (screen === 'teams' && !teamsData) {
|
|
141
|
+
setLoading(true);
|
|
142
|
+
const nextTeams = await loadTeamsForTui();
|
|
143
|
+
if (!cancelled) setTeamsData(nextTeams);
|
|
144
|
+
}
|
|
145
|
+
if (screen === 'schedules' && !schedulesData) {
|
|
146
|
+
setLoading(true);
|
|
147
|
+
const nextSchedules = await loadSchedulesForTui();
|
|
148
|
+
if (!cancelled) setSchedulesData(nextSchedules);
|
|
149
|
+
}
|
|
150
|
+
if (screen === 'policies' && !policiesData) {
|
|
151
|
+
setLoading(true);
|
|
152
|
+
const nextPolicies = await loadEscalationPoliciesForTui();
|
|
153
|
+
if (!cancelled) setPoliciesData(nextPolicies);
|
|
154
|
+
}
|
|
155
|
+
if (screen === 'schedule-members' && selectedTeam?.id && !teamMembersData) {
|
|
156
|
+
setLoading(true);
|
|
157
|
+
const nextMembers = await loadTeamMembersForTui(selectedTeam.id);
|
|
158
|
+
if (!cancelled) setTeamMembersData(nextMembers);
|
|
159
|
+
}
|
|
160
|
+
if (screen === 'add-members-picker' && selectedTeam?.id && !addableUsers) {
|
|
161
|
+
setLoading(true);
|
|
162
|
+
const nextAddable = await loadAddableUsersForTui(selectedTeam.id);
|
|
163
|
+
if (!cancelled) setAddableUsers(nextAddable);
|
|
164
|
+
}
|
|
165
|
+
if (screen === 'one-shot-members' && !directoryUsers) {
|
|
166
|
+
setLoading(true);
|
|
167
|
+
const dir = await loadDirectoryUsersForTui();
|
|
168
|
+
if (!cancelled) setDirectoryUsers(dir);
|
|
169
|
+
}
|
|
170
|
+
if (!cancelled) {
|
|
171
|
+
setLoading(false);
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
|
|
175
|
+
return () => {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
};
|
|
178
|
+
}, [screen]);
|
|
179
|
+
|
|
180
|
+
// Detect existing status pages at the start of the guided flow.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (screen !== 'sp-start' || spExisting !== null) return undefined;
|
|
183
|
+
let cancelled = false;
|
|
184
|
+
setLoading(true);
|
|
185
|
+
void (async () => {
|
|
186
|
+
const data = await loadStatusPagesForTui();
|
|
187
|
+
if (!cancelled) {
|
|
188
|
+
setSpExisting(data || { pages: [] });
|
|
189
|
+
setLoading(false);
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
return () => {
|
|
193
|
+
cancelled = true;
|
|
194
|
+
};
|
|
195
|
+
}, [screen, spExisting]);
|
|
196
|
+
|
|
197
|
+
// "Set up your public status page" jumps straight to the seeded public page
|
|
198
|
+
// (no picker, no create-vs-customize branch). If none exists, fall to create.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (screen !== 'sp-start' || spExisting === null || !formState.sp?.directPublic) return undefined;
|
|
201
|
+
const publicPage = (spExisting.pages || []).find((p) => p.public);
|
|
202
|
+
if (publicPage) {
|
|
203
|
+
// Walk them through editing the existing public page (update, not create).
|
|
204
|
+
setFormState((prev) => ({ ...prev, sp: { ...(prev.sp || {}), editId: publicPage.id, existingPage: publicPage, title: publicPage.title } }));
|
|
205
|
+
}
|
|
206
|
+
setScreen('sp-name'); // no public page → create one; otherwise walk through editing it
|
|
207
|
+
return undefined;
|
|
208
|
+
}, [screen, spExisting]);
|
|
209
|
+
|
|
210
|
+
// Load services/functionalities to offer as status-page components.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if ((screen !== 'sp-components' && screen !== 'sp-edit-components') || spComponents !== null) return undefined;
|
|
213
|
+
let cancelled = false;
|
|
214
|
+
setLoading(true);
|
|
215
|
+
void (async () => {
|
|
216
|
+
const data = await loadStatusPageComponentsForTui();
|
|
217
|
+
if (!cancelled) {
|
|
218
|
+
setSpComponents(data || { components: [] });
|
|
219
|
+
setLoading(false);
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
return () => {
|
|
223
|
+
cancelled = true;
|
|
224
|
+
};
|
|
225
|
+
}, [screen, spComponents]);
|
|
226
|
+
|
|
227
|
+
// Load the signed-in user's existing phone (for the pre-flight screen) so we
|
|
228
|
+
// can show it instead of offering to add one.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (screen !== 'one-shot-prereqs' || userPhone !== null) return undefined;
|
|
231
|
+
let cancelled = false;
|
|
232
|
+
void (async () => {
|
|
233
|
+
const info = await loadCurrentUserPhoneForTui();
|
|
234
|
+
if (!cancelled) setUserPhone(info || { hasPhone: false, phone: null });
|
|
235
|
+
})();
|
|
236
|
+
return () => {
|
|
237
|
+
cancelled = true;
|
|
238
|
+
};
|
|
239
|
+
}, [screen, userPhone]);
|
|
240
|
+
|
|
241
|
+
// Landing on a menu ends any in-progress flow: clear the picked team and the
|
|
242
|
+
// pending-action form so a later flow can't act on a stale team selection.
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (screen === 'menu' || screen === 'general-menu') {
|
|
245
|
+
setSelectedTeam(null);
|
|
246
|
+
setFormState({});
|
|
247
|
+
}
|
|
248
|
+
}, [screen]);
|
|
249
|
+
|
|
250
|
+
const leave = () => {
|
|
251
|
+
onExit?.();
|
|
252
|
+
exit();
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Drop the cached workspace snapshot so the next menu/data visit refetches.
|
|
256
|
+
// Called after any action that may have changed the workspace.
|
|
257
|
+
const clearWorkspaceCache = () => {
|
|
258
|
+
setState(null);
|
|
259
|
+
setTeamsData(null);
|
|
260
|
+
setSchedulesData(null);
|
|
261
|
+
setPoliciesData(null);
|
|
262
|
+
setTeamMembersData(null);
|
|
263
|
+
setAddableUsers(null);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Loading takes precedence over the current screen, so a loader shows even
|
|
267
|
+
// while we're still "on" the auth-token / data screens.
|
|
268
|
+
if (loading) {
|
|
269
|
+
if (screen === 'auth-token') {
|
|
270
|
+
return h(LoadingScreen, {
|
|
271
|
+
title: 'Signing you in…',
|
|
272
|
+
detail: 'Verifying your token with Rootly.'
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return h(LoadingScreen, {
|
|
276
|
+
title: screen === 'menu' ? 'Loading Rootly workspace…' : 'Loading Rootly data…',
|
|
277
|
+
detail: authContext?.label || 'Checking your current sign-in and workspace state.'
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (screen === 'welcome') {
|
|
282
|
+
return h(WelcomeScreen, {
|
|
283
|
+
lines: [
|
|
284
|
+
'Get from an empty workspace to incident-ready on-call in minutes.',
|
|
285
|
+
'',
|
|
286
|
+
'A handful of guided steps covers your teams, schedules, escalation, alerting, and integrations. No docs required.'
|
|
287
|
+
],
|
|
288
|
+
// Head to the menu, whose effect checks the keychain for a stored
|
|
289
|
+
// sign-in: returning users skip auth entirely, and only an unauthed
|
|
290
|
+
// session gets routed on to the sign-in chooser.
|
|
291
|
+
onContinue: () => setScreen('menu'),
|
|
292
|
+
onExit: leave
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (screen === 'auth-method') {
|
|
297
|
+
const hasAuth = Boolean(authContext?.hasAuth);
|
|
298
|
+
return h(OptionScreen, {
|
|
299
|
+
title: 'Sign in with Rootly',
|
|
300
|
+
lines: hasAuth
|
|
301
|
+
? [
|
|
302
|
+
authContext?.label || 'A Rootly sign-in is already stored on this machine.',
|
|
303
|
+
'',
|
|
304
|
+
'Keep it, or sign in another way.'
|
|
305
|
+
]
|
|
306
|
+
: [
|
|
307
|
+
'Choose how to sign in to Rootly.',
|
|
308
|
+
'',
|
|
309
|
+
'Browser sign-in uses OAuth. An API token works for the full setup and automation.'
|
|
310
|
+
],
|
|
311
|
+
options: [
|
|
312
|
+
...(hasAuth ? [{ label: 'Keep current sign-in', value: 'keep' }] : []),
|
|
313
|
+
{ label: 'Browser sign-in', value: 'browser' },
|
|
314
|
+
{ label: 'API token', value: 'token' },
|
|
315
|
+
...(hasAuth ? [] : [{ label: 'Create a Rootly account', value: 'signup' }]),
|
|
316
|
+
{ label: 'Exit', value: 'exit' }
|
|
317
|
+
],
|
|
318
|
+
onSelect: async (option) => {
|
|
319
|
+
if (option.value === 'keep') {
|
|
320
|
+
setScreen('menu');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (option.value === 'exit') {
|
|
324
|
+
// Exit, with the option to keep or delete the stored sign-in.
|
|
325
|
+
setScreen('exit-confirm');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (option.value === 'signup') {
|
|
329
|
+
// No headless signup API — hand off to the web (it's bot-protected and
|
|
330
|
+
// needs email confirmation). They come back and sign in after.
|
|
331
|
+
setLoading(true);
|
|
332
|
+
const opened = await openExternalUrlForTui(SIGNUP_URL);
|
|
333
|
+
setLoading(false);
|
|
334
|
+
setResultScreen({
|
|
335
|
+
title: 'Create a Rootly account',
|
|
336
|
+
lines: [
|
|
337
|
+
opened?.opened
|
|
338
|
+
? 'Opened the Rootly sign-up page in your browser.'
|
|
339
|
+
: 'Open this link to create your Rootly account:',
|
|
340
|
+
'',
|
|
341
|
+
SIGNUP_URL,
|
|
342
|
+
'',
|
|
343
|
+
'Once your account is set up, come back and sign in with an API token.'
|
|
344
|
+
],
|
|
345
|
+
next: 'auth-method'
|
|
346
|
+
});
|
|
347
|
+
setScreen('result');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (option.value === 'token') {
|
|
351
|
+
setScreen('auth-token');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (option.value === 'browser') {
|
|
355
|
+
setLoading(true);
|
|
356
|
+
const result = await authenticateWithBrowserForTui();
|
|
357
|
+
setLoading(false);
|
|
358
|
+
if (result.ok) {
|
|
359
|
+
setAuthContext(null);
|
|
360
|
+
clearWorkspaceCache();
|
|
361
|
+
setScreen('menu');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
setResultScreen({ title: 'Sign-in failed', lines: [friendlyError(result.summary)], next: 'auth-method' });
|
|
365
|
+
setScreen('result');
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
onBack: () => setScreen(hasAuth ? 'menu' : 'welcome')
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (screen === 'auth-token') {
|
|
373
|
+
const hasAuth = Boolean(authContext?.hasAuth);
|
|
374
|
+
return h(TextEntryScreen, {
|
|
375
|
+
title: 'Authorize with a Rootly API token',
|
|
376
|
+
prompt: hasAuth
|
|
377
|
+
? 'Paste a token to replace your stored one.'
|
|
378
|
+
: 'Paste your Rootly API token below.',
|
|
379
|
+
lines: [
|
|
380
|
+
'Need a token? Create one in Rootly:',
|
|
381
|
+
'Organization Settings → API Keys → New API Key',
|
|
382
|
+
'Use a Global key with write access'
|
|
383
|
+
],
|
|
384
|
+
link: 'Docs: https://docs.rootly.com/api-reference/overview',
|
|
385
|
+
placeholder: 'token',
|
|
386
|
+
hidden: true,
|
|
387
|
+
onSubmit: async (value) => {
|
|
388
|
+
setLoading(true);
|
|
389
|
+
const result = await authenticateWithApiTokenForTui(value);
|
|
390
|
+
if (result.ok) {
|
|
391
|
+
setAuthContext(null);
|
|
392
|
+
clearWorkspaceCache();
|
|
393
|
+
// False timeout so the sign-in loader is visible instead of flashing by.
|
|
394
|
+
await new Promise((resolve) => setTimeout(resolve, 2300));
|
|
395
|
+
setLoading(false);
|
|
396
|
+
setScreen('menu');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
setLoading(false);
|
|
400
|
+
setResultScreen({
|
|
401
|
+
title: 'Auth failed',
|
|
402
|
+
lines: [friendlyError(result.summary)],
|
|
403
|
+
next: 'auth-token'
|
|
404
|
+
});
|
|
405
|
+
setScreen('result');
|
|
406
|
+
},
|
|
407
|
+
onBack: () => setScreen('auth-method')
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (screen === 'load-failed') {
|
|
412
|
+
return h(LoadFailedScreen, {
|
|
413
|
+
onBack: () => setScreen('menu'),
|
|
414
|
+
onExit: leave
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (screen === 'auth-recovery') {
|
|
419
|
+
return h(OptionScreen, {
|
|
420
|
+
title: 'Auth needs attention',
|
|
421
|
+
lines: [
|
|
422
|
+
authRecovery?.label || 'A stored Rootly sign-in was found.',
|
|
423
|
+
'',
|
|
424
|
+
authRecovery?.isBrowserSession
|
|
425
|
+
? 'Browser sign-in completed, but this OAuth session cannot read the workspace setup APIs yet.'
|
|
426
|
+
: 'The stored sign-in could not read this Rootly workspace.',
|
|
427
|
+
'',
|
|
428
|
+
authRecovery?.isBrowserSession
|
|
429
|
+
? 'Sign in with an API token for now, or retry browser sign-in after Rootly grants workspace API access.'
|
|
430
|
+
: 'Sign in again to continue.'
|
|
431
|
+
],
|
|
432
|
+
options: [
|
|
433
|
+
{ label: 'Sign in again', value: 'reauth' },
|
|
434
|
+
{ label: 'Exit wizard', value: 'exit' }
|
|
435
|
+
],
|
|
436
|
+
onSelect: async (option) => {
|
|
437
|
+
if (option.value === 'exit') {
|
|
438
|
+
leave();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
await deleteTokenForTui();
|
|
442
|
+
setAuthRecovery(null);
|
|
443
|
+
setAuthContext(null);
|
|
444
|
+
setScreen('auth-method');
|
|
445
|
+
},
|
|
446
|
+
onBack: leave
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (screen === 'result' && resultScreen) {
|
|
451
|
+
return h(ResultScreen, {
|
|
452
|
+
title: resultScreen.title,
|
|
453
|
+
lines: resultScreen.lines,
|
|
454
|
+
actions: resultScreen.actions || [],
|
|
455
|
+
onContinue: () => {
|
|
456
|
+
// Results that return to the menu may follow a mutation; refresh the
|
|
457
|
+
// workspace snapshot so coverage reflects the change.
|
|
458
|
+
if (resultScreen.next === 'menu') clearWorkspaceCache();
|
|
459
|
+
setScreen(resultScreen.next);
|
|
460
|
+
},
|
|
461
|
+
onExit: leave,
|
|
462
|
+
continueLabel: resultScreen.continueLabel || (resultScreen.next === 'menu' ? 'Continue' : 'Try again')
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (screen === 'status') {
|
|
467
|
+
return h(StatusScreen, {
|
|
468
|
+
state,
|
|
469
|
+
onBack: () => setScreen('menu')
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (screen === 'general-menu') {
|
|
474
|
+
return h(OptionScreen, {
|
|
475
|
+
title: 'General setup',
|
|
476
|
+
context: 'menu',
|
|
477
|
+
lines: ['Everything beyond the recommended next step.'],
|
|
478
|
+
options: [
|
|
479
|
+
{ label: 'Setup: teams, members, schedules, escalation', value: 'setup' },
|
|
480
|
+
{ label: 'Integrations: Slack, alert sources, vendors', value: 'integrations' },
|
|
481
|
+
{ label: 'Verify: test alerting and incidents', value: 'verify' },
|
|
482
|
+
{ label: 'Inspect: status, teams, schedules', value: 'inspect' },
|
|
483
|
+
{ label: 'Set up MCP / IDE', value: 'mcp' },
|
|
484
|
+
{ label: 'Chat with us', value: 'chat' },
|
|
485
|
+
{ label: 'Back', value: 'back' }
|
|
486
|
+
],
|
|
487
|
+
onSelect: (option) => {
|
|
488
|
+
if (option.value === 'back') {
|
|
489
|
+
setScreen('menu');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (option.value === 'chat') {
|
|
493
|
+
setScreen('chat-menu');
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (option.value === 'setup') {
|
|
497
|
+
setScreen('setup-menu');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (option.value === 'integrations') {
|
|
501
|
+
setScreen('integrations-menu');
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (option.value === 'verify') {
|
|
505
|
+
setScreen('verify-menu');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (option.value === 'inspect') {
|
|
509
|
+
setScreen('inspect');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (option.value === 'mcp') {
|
|
513
|
+
setScreen('mcp-menu');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
setScreen('menu');
|
|
517
|
+
},
|
|
518
|
+
onBack: () => setScreen('menu')
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (screen === 'chat-menu') {
|
|
523
|
+
return h(OptionScreen, {
|
|
524
|
+
title: 'Chat with us',
|
|
525
|
+
lines: ['Talk to the Rootly team.'],
|
|
526
|
+
options: [
|
|
527
|
+
{ label: 'Talk to our founder JJ (seriously)', value: 'chat-ceo' },
|
|
528
|
+
{ label: 'Book a demo with sales', value: 'book-demo' },
|
|
529
|
+
{ label: 'Back', value: 'back' }
|
|
530
|
+
],
|
|
531
|
+
onSelect: async (option) => {
|
|
532
|
+
if (option.value === 'back') {
|
|
533
|
+
setScreen('general-menu');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const handoff = option.value === 'book-demo'
|
|
537
|
+
? {
|
|
538
|
+
url: SALES_DEMO_URL,
|
|
539
|
+
title: 'Book a demo with sales',
|
|
540
|
+
opened: 'Opened the Rootly demo booking page in your browser.',
|
|
541
|
+
fallback: 'Open this link to book a demo with sales:'
|
|
542
|
+
}
|
|
543
|
+
: {
|
|
544
|
+
url: CEO_CAL_URL,
|
|
545
|
+
title: 'Talk to our founder JJ',
|
|
546
|
+
opened: "Opened JJ's calendar in your browser.",
|
|
547
|
+
fallback: 'Open this link to book a time with JJ:'
|
|
548
|
+
};
|
|
549
|
+
setLoading(true);
|
|
550
|
+
const result = await openExternalUrlForTui(handoff.url);
|
|
551
|
+
setLoading(false);
|
|
552
|
+
setResultScreen({
|
|
553
|
+
title: handoff.title,
|
|
554
|
+
lines: result.opened
|
|
555
|
+
? [handoff.opened]
|
|
556
|
+
: [handoff.fallback, '', handoff.url],
|
|
557
|
+
continueLabel: 'Continue',
|
|
558
|
+
next: 'chat-menu'
|
|
559
|
+
});
|
|
560
|
+
setScreen('result');
|
|
561
|
+
},
|
|
562
|
+
onBack: () => setScreen('general-menu')
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (screen === 'inspect') {
|
|
567
|
+
return h(OptionScreen, {
|
|
568
|
+
title: 'Inspect',
|
|
569
|
+
lines: ['Browse workspace status and coverage.'],
|
|
570
|
+
options: [
|
|
571
|
+
{ label: 'Workspace status', value: 'status' },
|
|
572
|
+
{ label: 'Teams', value: 'teams' },
|
|
573
|
+
{ label: 'Schedules', value: 'schedules' },
|
|
574
|
+
{ label: 'Escalation policies', value: 'policies' },
|
|
575
|
+
{ label: 'Back', value: 'back' }
|
|
576
|
+
],
|
|
577
|
+
onSelect: (option) => {
|
|
578
|
+
if (option.value === 'back') setScreen('general-menu');
|
|
579
|
+
else setScreen(option.value);
|
|
580
|
+
},
|
|
581
|
+
onBack: () => setScreen('general-menu')
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (screen === 'setup-menu') {
|
|
586
|
+
return h(OptionScreen, {
|
|
587
|
+
title: 'Setup',
|
|
588
|
+
lines: ['Choose the setup action you want to run.'],
|
|
589
|
+
options: [
|
|
590
|
+
{ label: 'Create a team', value: 'create-team' },
|
|
591
|
+
{ label: 'Add team members', value: 'add-members-picker' },
|
|
592
|
+
{ label: 'Create a schedule', value: 'create-schedule' },
|
|
593
|
+
{ label: 'Create an escalation policy', value: 'create-escalation' },
|
|
594
|
+
{ label: 'Create a status page', value: 'create-status-page' },
|
|
595
|
+
{ label: 'Back', value: 'back' }
|
|
596
|
+
],
|
|
597
|
+
onSelect: (option) => {
|
|
598
|
+
if (option.value === 'back') {
|
|
599
|
+
setScreen('general-menu');
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (option.value === 'create-team') {
|
|
603
|
+
setScreen('create-team');
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (option.value === 'create-status-page') {
|
|
607
|
+
// Launch the guided public status-page flow (detects existing pages first).
|
|
608
|
+
setSpExisting(null);
|
|
609
|
+
setFormState({ sp: { isPublic: true, returnTo: 'general-menu' } });
|
|
610
|
+
setScreen('sp-start');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
setFormState({ pendingAction: option.value === 'create-schedule' ? 'create-schedule-name' : option.value });
|
|
614
|
+
setScreen('team-picker');
|
|
615
|
+
},
|
|
616
|
+
onBack: () => setScreen('general-menu')
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (screen === 'verify-menu') {
|
|
621
|
+
return h(OptionScreen, {
|
|
622
|
+
title: 'Verify',
|
|
623
|
+
lines: ['Choose the verification flow you want to run.'],
|
|
624
|
+
options: [
|
|
625
|
+
{ label: 'Send a test alert', value: 'create-test-alert' },
|
|
626
|
+
{ label: 'Create a test incident', value: 'create-test-incident' },
|
|
627
|
+
{ label: 'Back', value: 'back' }
|
|
628
|
+
],
|
|
629
|
+
onSelect: (option) => {
|
|
630
|
+
if (option.value === 'back') {
|
|
631
|
+
setScreen('general-menu');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
setFormState({ pendingAction: option.value });
|
|
635
|
+
setScreen('team-picker');
|
|
636
|
+
},
|
|
637
|
+
onBack: () => setScreen('general-menu')
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (screen === 'integrations-menu') {
|
|
642
|
+
return h(OptionScreen, {
|
|
643
|
+
title: 'Integrations',
|
|
644
|
+
lines: ['Choose the integration flow you want to run.'],
|
|
645
|
+
options: [
|
|
646
|
+
{ label: 'Connect Slack for incidents', value: 'slack' },
|
|
647
|
+
{ label: 'Connect an alert source', value: 'alert-source' },
|
|
648
|
+
{ label: 'Connect vendor integration in Rootly web', value: 'vendor' },
|
|
649
|
+
{ label: 'Back', value: 'back' }
|
|
650
|
+
],
|
|
651
|
+
onSelect: async (option) => {
|
|
652
|
+
if (option.value === 'back') {
|
|
653
|
+
setScreen('general-menu');
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (option.value === 'alert-source') {
|
|
657
|
+
setFormState({ pendingAction: 'create-alert-source' });
|
|
658
|
+
setScreen('team-picker');
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (option.value === 'slack') {
|
|
662
|
+
setLoading(true);
|
|
663
|
+
const result = await startWebHandoffForTui({ kind: 'Slack', open: true });
|
|
664
|
+
setLoading(false);
|
|
665
|
+
setResultScreen({
|
|
666
|
+
title: result.ok ? 'Slack handoff ready' : 'Slack handoff failed',
|
|
667
|
+
lines: result.ok
|
|
668
|
+
? [
|
|
669
|
+
'Slack still uses the Rootly web flow.',
|
|
670
|
+
'',
|
|
671
|
+
result.data?.url ? hyperlink(result.data.url, '↗ Open Slack setup') : 'Open the Slack setup page in Rootly.',
|
|
672
|
+
'',
|
|
673
|
+
'Finish connecting Slack in your browser, then choose Continue to refresh your workspace status.'
|
|
674
|
+
]
|
|
675
|
+
: [friendlyError(result.summary)],
|
|
676
|
+
next: 'menu'
|
|
677
|
+
});
|
|
678
|
+
setScreen('result');
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
setScreen('vendor-menu');
|
|
682
|
+
},
|
|
683
|
+
onBack: () => setScreen('general-menu')
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (screen === 'vendor-menu') {
|
|
688
|
+
return h(OptionScreen, {
|
|
689
|
+
title: 'Vendor integration',
|
|
690
|
+
lines: ['These integrations still connect through Rootly web.'],
|
|
691
|
+
options: [
|
|
692
|
+
{ label: 'Datadog', value: 'Datadog' },
|
|
693
|
+
{ label: 'Grafana', value: 'Grafana' },
|
|
694
|
+
{ label: 'PagerDuty', value: 'PagerDuty' },
|
|
695
|
+
{ label: 'Opsgenie', value: 'Opsgenie' },
|
|
696
|
+
{ label: 'Sentry', value: 'Sentry' },
|
|
697
|
+
{ label: 'Back', value: 'back' }
|
|
698
|
+
],
|
|
699
|
+
onSelect: async (option) => {
|
|
700
|
+
if (option.value === 'back') {
|
|
701
|
+
setScreen('integrations-menu');
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
setLoading(true);
|
|
705
|
+
const result = await startWebHandoffForTui({ kind: option.value, open: true });
|
|
706
|
+
setLoading(false);
|
|
707
|
+
setResultScreen({
|
|
708
|
+
title: result.ok ? 'Vendor handoff ready' : 'Vendor handoff failed',
|
|
709
|
+
lines: result.ok
|
|
710
|
+
? [
|
|
711
|
+
`Opened ${option.value} in Rootly web.`,
|
|
712
|
+
...(result.data?.url ? ['', hyperlink(result.data.url, `↗ Open ${option.value} setup`)] : []),
|
|
713
|
+
'',
|
|
714
|
+
`Finish connecting ${option.value} in your browser, then choose Continue to refresh your workspace status.`
|
|
715
|
+
]
|
|
716
|
+
: [friendlyError(result.summary)],
|
|
717
|
+
next: 'menu'
|
|
718
|
+
});
|
|
719
|
+
setScreen('result');
|
|
720
|
+
},
|
|
721
|
+
onBack: () => setScreen('integrations-menu')
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (screen === 'mcp-menu') {
|
|
726
|
+
return h(OptionScreen, {
|
|
727
|
+
title: 'MCP / IDE setup',
|
|
728
|
+
lines: ['Configure the hosted Rootly MCP server for supported clients.'],
|
|
729
|
+
options: [
|
|
730
|
+
{ label: 'Preview Codex config', value: 'preview-codex' },
|
|
731
|
+
{ label: 'Apply Codex config', value: 'apply-codex' },
|
|
732
|
+
{ label: 'Back', value: 'back' }
|
|
733
|
+
],
|
|
734
|
+
onSelect: async (option) => {
|
|
735
|
+
if (option.value === 'back') {
|
|
736
|
+
setScreen('menu');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
setLoading(true);
|
|
740
|
+
if (option.value === 'preview-codex') {
|
|
741
|
+
const result = await previewMcpForTui({ clients: ['Codex'], auth: 'Use stored token' });
|
|
742
|
+
setLoading(false);
|
|
743
|
+
setMcpPreview(result);
|
|
744
|
+
setScreen('mcp-preview');
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const result = await applyMcpForTui({ clients: ['Codex'], auth: 'Use stored token' });
|
|
748
|
+
setLoading(false);
|
|
749
|
+
setResultScreen({
|
|
750
|
+
title: result.ok ? 'MCP configured' : 'MCP setup blocked',
|
|
751
|
+
lines: result.ok
|
|
752
|
+
? (result.data?.results || []).map((entry) => `${entry.client}: ${entry.targetPath}`)
|
|
753
|
+
: [friendlyError(result.summary)],
|
|
754
|
+
next: 'menu'
|
|
755
|
+
});
|
|
756
|
+
setScreen('result');
|
|
757
|
+
},
|
|
758
|
+
onBack: () => setScreen('menu')
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (screen === 'mcp-preview') {
|
|
763
|
+
return h(ListScreen, {
|
|
764
|
+
title: 'MCP preview',
|
|
765
|
+
items: (mcpPreview?.data?.preview || []).map((entry) => entry.replace(/\n/g, ' ')),
|
|
766
|
+
emptyLabel: 'No preview available.',
|
|
767
|
+
onBack: () => setScreen('mcp-menu')
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (screen === 'teams') {
|
|
772
|
+
return h(ListScreen, {
|
|
773
|
+
title: 'Teams',
|
|
774
|
+
items: (teamsData?.teams || []).map((team) => `${team.name} · ${team.memberCount || 0} members · ${team.scheduleCount || 0} schedules · ${team.escalationPolicyCount || 0} escalations`),
|
|
775
|
+
emptyLabel: 'No teams found.',
|
|
776
|
+
onBack: () => setScreen('inspect')
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (screen === 'schedules') {
|
|
781
|
+
return h(ListScreen, {
|
|
782
|
+
title: 'Schedules',
|
|
783
|
+
items: (schedulesData?.schedules || []).map((schedule) => {
|
|
784
|
+
const attrs = schedule.attributes || {};
|
|
785
|
+
const owners = Array.isArray(attrs.owner_group_ids) && attrs.owner_group_ids.length > 0
|
|
786
|
+
? `${attrs.owner_group_ids.length} teams`
|
|
787
|
+
: 'no teams';
|
|
788
|
+
return `${attrs.name || schedule.id} · ${attrs.all_time_coverage ? '24/7' : 'custom coverage'} · ${owners}`;
|
|
789
|
+
}),
|
|
790
|
+
emptyLabel: 'No schedules found.',
|
|
791
|
+
onBack: () => setScreen('inspect')
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (screen === 'policies') {
|
|
796
|
+
return h(ListScreen, {
|
|
797
|
+
title: 'Escalation policies',
|
|
798
|
+
items: (policiesData?.policies || []).map((policy) => {
|
|
799
|
+
const attrs = policy.attributes || {};
|
|
800
|
+
const teamCount = Array.isArray(attrs.group_ids) ? attrs.group_ids.length : 0;
|
|
801
|
+
return `${attrs.name || policy.id} · ${teamCount} teams · repeats ${attrs.repeat_count ?? 0}`;
|
|
802
|
+
}),
|
|
803
|
+
emptyLabel: 'No escalation policies found.',
|
|
804
|
+
onBack: () => setScreen('inspect')
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (screen === 'setup-complete') {
|
|
809
|
+
return h(OptionScreen, {
|
|
810
|
+
context: 'all set',
|
|
811
|
+
header: h(
|
|
812
|
+
Box,
|
|
813
|
+
{ flexDirection: 'column', alignItems: 'center', marginBottom: 1 },
|
|
814
|
+
h(Celebration),
|
|
815
|
+
h(BigText, { text: 'INCIDENT-READY', color: palette.brand })
|
|
816
|
+
),
|
|
817
|
+
lines: [
|
|
818
|
+
'Your core Rootly setup is in place — teams, on-call, escalation, and alerting.',
|
|
819
|
+
'',
|
|
820
|
+
'Your account comes with a public status page — set it up to keep customers informed.'
|
|
821
|
+
],
|
|
822
|
+
options: [
|
|
823
|
+
{ label: 'Set up your public status page', value: 'status-page' },
|
|
824
|
+
{ label: 'Continue configuring the platform', value: 'configure' },
|
|
825
|
+
{ label: 'Talk to our founder JJ (seriously)', value: 'chat-ceo' },
|
|
826
|
+
{ label: 'Book a demo with sales', value: 'book-demo' },
|
|
827
|
+
{ label: 'Back to menu', value: 'back' }
|
|
828
|
+
],
|
|
829
|
+
onSelect: async (option) => {
|
|
830
|
+
if (option.value === 'status-page') {
|
|
831
|
+
// Go straight to the seeded public page (no picker, no create branch).
|
|
832
|
+
setSpExisting(null);
|
|
833
|
+
setFormState({ sp: { isPublic: true, directPublic: true, returnTo: 'setup-complete' } });
|
|
834
|
+
setScreen('sp-start');
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (option.value === 'configure') {
|
|
838
|
+
setScreen('general-menu');
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (option.value === 'back') {
|
|
842
|
+
setScreen('menu');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const handoff = option.value === 'book-demo'
|
|
846
|
+
? {
|
|
847
|
+
url: SALES_DEMO_URL,
|
|
848
|
+
title: 'Book a demo with sales',
|
|
849
|
+
opened: 'Opened the Rootly demo booking page in your browser.',
|
|
850
|
+
fallback: 'Open this link to book a demo with sales:'
|
|
851
|
+
}
|
|
852
|
+
: {
|
|
853
|
+
url: CEO_CAL_URL,
|
|
854
|
+
title: 'Talk to our founder JJ',
|
|
855
|
+
opened: "Opened JJ's calendar in your browser.",
|
|
856
|
+
fallback: 'Open this link to book a time with JJ:'
|
|
857
|
+
};
|
|
858
|
+
setLoading(true);
|
|
859
|
+
const result = await openExternalUrlForTui(handoff.url);
|
|
860
|
+
setLoading(false);
|
|
861
|
+
setResultScreen({
|
|
862
|
+
title: handoff.title,
|
|
863
|
+
lines: result.opened
|
|
864
|
+
? [handoff.opened]
|
|
865
|
+
: [handoff.fallback, '', handoff.url],
|
|
866
|
+
continueLabel: 'Continue',
|
|
867
|
+
next: 'setup-complete'
|
|
868
|
+
});
|
|
869
|
+
setScreen('result');
|
|
870
|
+
},
|
|
871
|
+
onBack: () => setScreen('menu')
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (screen === 'one-shot') {
|
|
876
|
+
return h(OptionScreen, {
|
|
877
|
+
title: 'Quick start',
|
|
878
|
+
lines: [
|
|
879
|
+
'Set up everything at once: a team, an on-call schedule, an escalation policy, and an alert source, then fire a test alert and open a test incident so you can see the full flow.',
|
|
880
|
+
'',
|
|
881
|
+
'Next you’ll pick who goes on the team and on call. Anything that already exists is reused.'
|
|
882
|
+
],
|
|
883
|
+
options: [
|
|
884
|
+
{ label: 'Continue', value: 'continue' },
|
|
885
|
+
{ label: 'Back to menu', value: 'back' }
|
|
886
|
+
],
|
|
887
|
+
onSelect: (option) => {
|
|
888
|
+
setScreen(option.value === 'continue' ? 'one-shot-prereqs' : 'menu');
|
|
889
|
+
},
|
|
890
|
+
onBack: () => setScreen('menu')
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (screen === 'one-shot-prereqs') {
|
|
895
|
+
// Wait for the phone lookup before rendering, so the screen doesn't flash
|
|
896
|
+
// the "no phone" layout and then flip to "phone on file" when it resolves.
|
|
897
|
+
if (userPhone === null) {
|
|
898
|
+
return h(LoadingScreen, {
|
|
899
|
+
title: 'Getting things ready…',
|
|
900
|
+
detail: 'Checking how you’ll be notified.'
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const openHandoff = async (kind, title, where) => {
|
|
905
|
+
setLoading(true);
|
|
906
|
+
const result = await startWebHandoffForTui({ kind, open: true });
|
|
907
|
+
setLoading(false);
|
|
908
|
+
const url = result?.data?.url;
|
|
909
|
+
setResultScreen({
|
|
910
|
+
title,
|
|
911
|
+
lines: result?.data?.opened
|
|
912
|
+
? [`Opened ${where} in your browser.`, '', 'We’ll be here when you’re done.', ...(url ? ['', url] : [])]
|
|
913
|
+
: ['Open this link to finish, then continue here:', ...(url ? ['', url] : [])],
|
|
914
|
+
next: 'one-shot-prereqs'
|
|
915
|
+
});
|
|
916
|
+
setScreen('result');
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const hasPhone = Boolean(userPhone?.hasPhone);
|
|
920
|
+
const lines = hasPhone
|
|
921
|
+
? [
|
|
922
|
+
'A test alert pages whoever is on call.',
|
|
923
|
+
'',
|
|
924
|
+
{ text: `✓ Phone on file — ${formatPhone(userPhone?.phone) || 'added'}`, color: palette.success, bold: true },
|
|
925
|
+
'',
|
|
926
|
+
'Connect Slack too if you like, then continue.'
|
|
927
|
+
]
|
|
928
|
+
: [
|
|
929
|
+
'A test alert and incident only reach you if you have somewhere to be notified.',
|
|
930
|
+
'',
|
|
931
|
+
'Connect Slack and/or add a phone number, then continue. You can also skip and do this later.'
|
|
932
|
+
];
|
|
933
|
+
|
|
934
|
+
return h(OptionScreen, {
|
|
935
|
+
title: 'Before we set up',
|
|
936
|
+
lines,
|
|
937
|
+
// With a phone on file, Continue is the default (first). Without one, lead
|
|
938
|
+
// with adding a number — otherwise the test alert can't page anyone.
|
|
939
|
+
options: hasPhone
|
|
940
|
+
? [
|
|
941
|
+
{ label: 'Continue setup', value: 'continue' },
|
|
942
|
+
{ label: 'Connect Slack', value: 'slack' },
|
|
943
|
+
{ label: 'Back to menu', value: 'back' }
|
|
944
|
+
]
|
|
945
|
+
: [
|
|
946
|
+
{ label: 'Add a phone number (recommended — so the test alert pages you)', value: 'phone' },
|
|
947
|
+
{ label: 'Continue setup', value: 'continue' },
|
|
948
|
+
{ label: 'Connect Slack', value: 'slack' },
|
|
949
|
+
{ label: 'Back to menu', value: 'back' }
|
|
950
|
+
],
|
|
951
|
+
onSelect: async (option) => {
|
|
952
|
+
if (option.value === 'slack') {
|
|
953
|
+
await openHandoff('Slack', 'Connect Slack', 'Slack setup');
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (option.value === 'phone') {
|
|
957
|
+
setScreen('phone-entry');
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (option.value === 'continue') {
|
|
961
|
+
// No phone on file → the test alert may not reach anyone. Confirm
|
|
962
|
+
// before running so the demo doesn't silently page no one.
|
|
963
|
+
setScreen(hasPhone ? 'one-shot-members' : 'one-shot-no-notify');
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
setScreen('menu');
|
|
967
|
+
},
|
|
968
|
+
onBack: () => setScreen('menu')
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (screen === 'one-shot-no-notify') {
|
|
973
|
+
return h(OptionScreen, {
|
|
974
|
+
title: 'No phone number yet',
|
|
975
|
+
lines: [
|
|
976
|
+
'You don’t have a phone number on file.',
|
|
977
|
+
'',
|
|
978
|
+
'If you connected Slack, you’re all set — the test alert will reach you there. Otherwise it won’t page anyone.',
|
|
979
|
+
'',
|
|
980
|
+
'Add a number, or continue anyway.'
|
|
981
|
+
],
|
|
982
|
+
options: [
|
|
983
|
+
{ label: 'Add a phone number', value: 'phone' },
|
|
984
|
+
{ label: 'Continue anyway', value: 'continue' },
|
|
985
|
+
{ label: 'Back', value: 'back' }
|
|
986
|
+
],
|
|
987
|
+
onSelect: (option) => {
|
|
988
|
+
if (option.value === 'phone') setScreen('phone-entry');
|
|
989
|
+
else if (option.value === 'continue') setScreen('one-shot-members');
|
|
990
|
+
else setScreen('one-shot-prereqs');
|
|
991
|
+
},
|
|
992
|
+
onBack: () => setScreen('one-shot-prereqs')
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (screen === 'phone-entry') {
|
|
997
|
+
return h(TextEntryScreen, {
|
|
998
|
+
title: 'Add a phone number',
|
|
999
|
+
prompt: 'Enter your mobile number. We’ll text you a code to verify it.',
|
|
1000
|
+
lines: ['Outside the US or Canada? Start with your country code, like +44.'],
|
|
1001
|
+
placeholder: '(415) 555-0123',
|
|
1002
|
+
onSubmit: async (value) => {
|
|
1003
|
+
setLoading(true);
|
|
1004
|
+
const result = await startPhoneVerificationForTui({ phone: value });
|
|
1005
|
+
setLoading(false);
|
|
1006
|
+
if (result.ok) {
|
|
1007
|
+
setFormState((prev) => ({ ...prev, phoneNumberId: result.data.phoneNumberId, phone: result.data.phone }));
|
|
1008
|
+
setScreen('phone-code');
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
setResultScreen({ title: 'Add a phone number', lines: [friendlyError(result.summary)], next: 'phone-entry', continueLabel: 'Try again' });
|
|
1012
|
+
setScreen('result');
|
|
1013
|
+
},
|
|
1014
|
+
onBack: () => setScreen('one-shot-prereqs')
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (screen === 'phone-code') {
|
|
1019
|
+
return h(TextEntryScreen, {
|
|
1020
|
+
title: 'Verify phone number',
|
|
1021
|
+
prompt: `Enter the 6-digit code we texted to ${formState.phone || 'your phone'}.`,
|
|
1022
|
+
lines: ['It can take a few seconds to arrive.'],
|
|
1023
|
+
placeholder: '123456',
|
|
1024
|
+
onSubmit: async (value) => {
|
|
1025
|
+
setLoading(true);
|
|
1026
|
+
const result = await confirmPhoneVerificationForTui({ phoneNumberId: formState.phoneNumberId, code: value });
|
|
1027
|
+
setLoading(false);
|
|
1028
|
+
if (result.ok) {
|
|
1029
|
+
setUserPhone(null); // refresh so the pre-flight shows the new number
|
|
1030
|
+
setResultScreen({
|
|
1031
|
+
title: 'Phone verified',
|
|
1032
|
+
lines: ['Your phone number is verified and ready for paging.'],
|
|
1033
|
+
next: 'one-shot-prereqs',
|
|
1034
|
+
continueLabel: 'Done'
|
|
1035
|
+
});
|
|
1036
|
+
setScreen('result');
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
setResultScreen({
|
|
1040
|
+
title: 'Verification failed',
|
|
1041
|
+
lines: [friendlyError(result.summary), 'Check the code and try again.'],
|
|
1042
|
+
next: 'phone-code',
|
|
1043
|
+
continueLabel: 'Try again'
|
|
1044
|
+
});
|
|
1045
|
+
setScreen('result');
|
|
1046
|
+
},
|
|
1047
|
+
onBack: () => setScreen('one-shot-prereqs')
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (screen === 'one-shot-members') {
|
|
1052
|
+
const options = (directoryUsers?.users || []).map((user) => ({
|
|
1053
|
+
label: user.name ? `${user.name}${user.email ? ` — ${user.email}` : ''}` : (user.email || user.id),
|
|
1054
|
+
value: user.id
|
|
1055
|
+
}));
|
|
1056
|
+
|
|
1057
|
+
// With no directory (an OAuth session can't list /v1/users) or only the
|
|
1058
|
+
// signed-in user available, a check/uncheck list of one is pointless — just
|
|
1059
|
+
// confirm and add that user (the chain puts them on call). Show the
|
|
1060
|
+
// multiselect only when there are several people to choose from.
|
|
1061
|
+
if (options.length <= 1) {
|
|
1062
|
+
const onlyUser = options[0];
|
|
1063
|
+
return h(OptionScreen, {
|
|
1064
|
+
title: 'Quick start',
|
|
1065
|
+
lines: [
|
|
1066
|
+
onlyUser
|
|
1067
|
+
? 'You’ll be added to the team and put on call. (Sign in with an API key to add more teammates.)'
|
|
1068
|
+
: directoryUsers?.userLookupUnavailable
|
|
1069
|
+
? 'This sign-in can’t list other Rootly users, so just you will be added to the team and put on call.'
|
|
1070
|
+
: 'No other users to add — just you will be added to the team and put on call.'
|
|
1071
|
+
],
|
|
1072
|
+
options: [
|
|
1073
|
+
{ label: 'Run setup', value: 'run' },
|
|
1074
|
+
{ label: 'Back to menu', value: 'back' }
|
|
1075
|
+
],
|
|
1076
|
+
onSelect: (option) => {
|
|
1077
|
+
if (option.value === 'back') {
|
|
1078
|
+
setScreen('menu');
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
setFormState({ oneShotMemberIds: onlyUser ? [onlyUser.value] : [] });
|
|
1082
|
+
setScreen('one-shot-running');
|
|
1083
|
+
},
|
|
1084
|
+
onBack: () => setScreen('menu')
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return h(MultiSelectScreen, {
|
|
1089
|
+
title: 'Who goes on the team & on call?',
|
|
1090
|
+
options,
|
|
1091
|
+
onSubmit: (selectedOptions) => {
|
|
1092
|
+
setFormState({ oneShotMemberIds: selectedOptions.map((option) => option.value) });
|
|
1093
|
+
setScreen('one-shot-running');
|
|
1094
|
+
},
|
|
1095
|
+
onBack: () => setScreen('menu')
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (screen === 'one-shot-running') {
|
|
1100
|
+
const usersById = {};
|
|
1101
|
+
(directoryUsers?.users || []).forEach((user) => {
|
|
1102
|
+
usersById[user.id] = user;
|
|
1103
|
+
});
|
|
1104
|
+
return h(OneShotRunnerScreen, {
|
|
1105
|
+
memberIds: formState.oneShotMemberIds || [],
|
|
1106
|
+
usersById,
|
|
1107
|
+
runner: runOneShotSetupForTui,
|
|
1108
|
+
// A successful run lands on the incident-ready screen (with the CEO nudge).
|
|
1109
|
+
onContinue: () => {
|
|
1110
|
+
clearWorkspaceCache();
|
|
1111
|
+
setScreen('setup-complete');
|
|
1112
|
+
},
|
|
1113
|
+
onMenu: () => {
|
|
1114
|
+
clearWorkspaceCache();
|
|
1115
|
+
setScreen('menu');
|
|
1116
|
+
},
|
|
1117
|
+
onRetry: () => {
|
|
1118
|
+
clearWorkspaceCache();
|
|
1119
|
+
setScreen('one-shot-members');
|
|
1120
|
+
},
|
|
1121
|
+
onExit: leave
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (screen === 'placeholder') {
|
|
1126
|
+
return h(OptionScreen, {
|
|
1127
|
+
title: 'Nothing to do here',
|
|
1128
|
+
lines: [
|
|
1129
|
+
'There is no recommended action to run right now.',
|
|
1130
|
+
'',
|
|
1131
|
+
'Use the menu to inspect your workspace, run setup, or connect integrations.'
|
|
1132
|
+
],
|
|
1133
|
+
options: [
|
|
1134
|
+
{ label: 'Back to main menu', value: 'back' }
|
|
1135
|
+
],
|
|
1136
|
+
onSelect: () => setScreen('menu'),
|
|
1137
|
+
onBack: () => setScreen('menu')
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (screen === 'exit-confirm') {
|
|
1142
|
+
return h(OptionScreen, {
|
|
1143
|
+
title: 'Exit',
|
|
1144
|
+
lines: ['Keep your saved sign-in for next time?'],
|
|
1145
|
+
// Default (first / preselected) option keeps the sign-in.
|
|
1146
|
+
options: [
|
|
1147
|
+
{ label: 'Just exit', value: 'keep' },
|
|
1148
|
+
{ label: 'Delete from keychain', value: 'delete' }
|
|
1149
|
+
],
|
|
1150
|
+
onSelect: async (option) => {
|
|
1151
|
+
if (option.value === 'delete') {
|
|
1152
|
+
setLoading(true);
|
|
1153
|
+
await deleteTokenForTui();
|
|
1154
|
+
setLoading(false);
|
|
1155
|
+
}
|
|
1156
|
+
leave();
|
|
1157
|
+
},
|
|
1158
|
+
onBack: () => setScreen('menu')
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (screen === 'create-team') {
|
|
1163
|
+
return h(TextEntryScreen, {
|
|
1164
|
+
title: 'Create a team',
|
|
1165
|
+
prompt: 'Enter the team name you want to create.',
|
|
1166
|
+
placeholder: 'Team name',
|
|
1167
|
+
initialValue: 'Incident Response',
|
|
1168
|
+
onSubmit: async (value) => {
|
|
1169
|
+
setLoading(true);
|
|
1170
|
+
const result = await createTeamForTui({ name: value });
|
|
1171
|
+
setLoading(false);
|
|
1172
|
+
setResultScreen({
|
|
1173
|
+
title: result.ok ? 'Team created' : 'Team setup needs attention',
|
|
1174
|
+
lines: result.ok
|
|
1175
|
+
? [`Team: ${result.data?.name || value}`, result.data?.id ? `Team ID: ${result.data.id}` : 'Team created']
|
|
1176
|
+
: [friendlyError(result.summary)],
|
|
1177
|
+
next: result.ok ? 'menu' : 'create-team'
|
|
1178
|
+
});
|
|
1179
|
+
setScreen('result');
|
|
1180
|
+
},
|
|
1181
|
+
onBack: () => setScreen('menu')
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ---- Guided status page flow (public): detect → name → auth → components → customize → publish ----
|
|
1186
|
+
const sp = formState.sp || {};
|
|
1187
|
+
const patchSp = (patch) => setFormState((prev) => ({ ...prev, sp: { ...(prev.sp || {}), ...patch } }));
|
|
1188
|
+
const spReturn = sp.returnTo || 'menu';
|
|
1189
|
+
|
|
1190
|
+
// The live public page lives at /teams/<org-slug>/status-pages/<slug>/public, but
|
|
1191
|
+
// the org slug isn't exposed in the API — so we open the account editor instead
|
|
1192
|
+
// (slug-keyed, always works; it previews the live page and links to it with the
|
|
1193
|
+
// correct URL Rootly builds server-side). Both helpers point there.
|
|
1194
|
+
const spViewUrl = (slug) => `${STATUS_PAGES_URL}/${slug}/edit`;
|
|
1195
|
+
const spManageUrl = (slug) => `${STATUS_PAGES_URL}/${slug}/edit`;
|
|
1196
|
+
|
|
1197
|
+
// Create-or-update the page (always published), then open it in the browser so
|
|
1198
|
+
// there's no URL to copy. Further tweaks (incl. disabling) happen in the web UI.
|
|
1199
|
+
const finalizeStatusPage = async () => {
|
|
1200
|
+
const fields = {
|
|
1201
|
+
title: sp.title,
|
|
1202
|
+
authenticationMethod: sp.authMethod || 'none',
|
|
1203
|
+
authenticationPassword: sp.authPassword || null,
|
|
1204
|
+
publish: true,
|
|
1205
|
+
// Only set components when the user actually chose some this session, so
|
|
1206
|
+
// editing a page without touching components doesn't wipe its existing set.
|
|
1207
|
+
...(sp.componentsTouched
|
|
1208
|
+
? { serviceIds: sp.serviceIds || [], functionalityIds: sp.functionalityIds || [] }
|
|
1209
|
+
: {})
|
|
1210
|
+
};
|
|
1211
|
+
setLoading(true);
|
|
1212
|
+
const result = sp.editId
|
|
1213
|
+
? await updateStatusPageForTui({ id: sp.editId, ...fields })
|
|
1214
|
+
: await createStatusPageForTui({ isPublic: sp.isPublic !== false, ...fields });
|
|
1215
|
+
setLoading(false);
|
|
1216
|
+
const slug = result.data?.slug || sp.existingPage?.slug;
|
|
1217
|
+
const isPublic = result.data?.public ?? (sp.isPublic !== false);
|
|
1218
|
+
const liveUrl = slug ? spViewUrl(slug, isPublic) : STATUS_PAGES_URL;
|
|
1219
|
+
if (result.ok) await openExternalUrlForTui(liveUrl);
|
|
1220
|
+
setResultScreen({
|
|
1221
|
+
title: result.ok ? 'Your status page is live' : 'Status page needs attention',
|
|
1222
|
+
lines: result.ok
|
|
1223
|
+
? [
|
|
1224
|
+
`${result.data?.title || sp.title}`,
|
|
1225
|
+
'',
|
|
1226
|
+
'Opened your status page in Rootly — preview it and customize it there.'
|
|
1227
|
+
]
|
|
1228
|
+
: [friendlyError(result.summary)],
|
|
1229
|
+
actions: result.ok
|
|
1230
|
+
? [{ label: 'Open the status page again', onSelect: () => openExternalUrlForTui(liveUrl) }]
|
|
1231
|
+
: [],
|
|
1232
|
+
continueLabel: result.ok ? 'Continue' : undefined,
|
|
1233
|
+
next: result.ok ? spReturn : 'sp-components'
|
|
1234
|
+
});
|
|
1235
|
+
setScreen('result');
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
if (screen === 'sp-start') {
|
|
1239
|
+
// While loading, or when jumping straight to the public page, show the loader
|
|
1240
|
+
// (the directPublic effect navigates away once pages are loaded).
|
|
1241
|
+
if (spExisting === null || sp.directPublic) {
|
|
1242
|
+
return h(LoadingScreen, { title: 'Status page', detail: 'Opening your public status page…' });
|
|
1243
|
+
}
|
|
1244
|
+
const existing = spExisting.pages || [];
|
|
1245
|
+
if (!existing.length) {
|
|
1246
|
+
return h(OptionScreen, {
|
|
1247
|
+
title: 'Create a status page',
|
|
1248
|
+
lines: ['No status page exists yet — let’s create one.'],
|
|
1249
|
+
options: [{ label: 'Create a status page', value: 'new' }, { label: 'Back', value: 'back' }],
|
|
1250
|
+
onSelect: (option) => setScreen(option.value === 'back' ? spReturn : 'sp-name'),
|
|
1251
|
+
onBack: () => setScreen(spReturn)
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
return h(OptionScreen, {
|
|
1255
|
+
title: 'Status pages',
|
|
1256
|
+
lines: [
|
|
1257
|
+
`Your workspace already has ${existing.length} status page${existing.length === 1 ? '' : 's'} (new accounts come with a default one).`,
|
|
1258
|
+
'',
|
|
1259
|
+
'Customize an existing page, or create a new one.'
|
|
1260
|
+
],
|
|
1261
|
+
options: [
|
|
1262
|
+
{ label: 'Customize an existing page', value: 'existing' },
|
|
1263
|
+
{ label: 'Create a new page', value: 'new' },
|
|
1264
|
+
{ label: 'Back', value: 'back' }
|
|
1265
|
+
],
|
|
1266
|
+
onSelect: (option) => {
|
|
1267
|
+
if (option.value === 'back') { setScreen(spReturn); return; }
|
|
1268
|
+
setScreen(option.value === 'existing' ? 'sp-pick-existing' : 'sp-name');
|
|
1269
|
+
},
|
|
1270
|
+
onBack: () => setScreen(spReturn)
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (screen === 'sp-pick-existing') {
|
|
1275
|
+
const existing = spExisting?.pages || [];
|
|
1276
|
+
return h(OptionScreen, {
|
|
1277
|
+
title: 'Pick a page to customize',
|
|
1278
|
+
lines: ['Choose the status page to update.'],
|
|
1279
|
+
options: [
|
|
1280
|
+
...existing.map((p) => ({
|
|
1281
|
+
label: `${p.title} · ${p.public ? 'public' : 'internal'} · ${p.published ? 'published' : 'draft'}`,
|
|
1282
|
+
value: p.id
|
|
1283
|
+
})),
|
|
1284
|
+
{ label: 'Back', value: 'back' }
|
|
1285
|
+
],
|
|
1286
|
+
onSelect: (option) => {
|
|
1287
|
+
if (option.value === 'back') { setScreen('sp-start'); return; }
|
|
1288
|
+
patchSp({ existingPage: existing.find((p) => p.id === option.value) || null });
|
|
1289
|
+
setScreen('sp-existing-actions');
|
|
1290
|
+
},
|
|
1291
|
+
onBack: () => setScreen('sp-start')
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (screen === 'sp-existing-actions') {
|
|
1296
|
+
const page = sp.existingPage || {};
|
|
1297
|
+
const slug = page.slug;
|
|
1298
|
+
const liveUrl = slug ? spViewUrl(slug, page.public) : STATUS_PAGES_URL;
|
|
1299
|
+
const manageUrl = slug ? spManageUrl(slug) : STATUS_PAGES_URL;
|
|
1300
|
+
return h(OptionScreen, {
|
|
1301
|
+
title: `Customize ${page.title || 'status page'}`,
|
|
1302
|
+
lines: [
|
|
1303
|
+
`${page.public ? 'Public' : 'Internal'} · ${page.published ? 'published' : 'draft'}.`,
|
|
1304
|
+
'',
|
|
1305
|
+
page.published
|
|
1306
|
+
? 'It’s live. Edit its settings here, or open the full editor in Rootly.'
|
|
1307
|
+
: 'Publish makes it live. Edit its settings here, or open the full editor in Rootly.'
|
|
1308
|
+
],
|
|
1309
|
+
options: [
|
|
1310
|
+
{ label: 'Edit settings', value: 'edit' },
|
|
1311
|
+
...(page.published ? [] : [{ label: 'Publish it now', value: 'publish' }]),
|
|
1312
|
+
{ label: 'Open it in Rootly (full editor)', value: 'open' },
|
|
1313
|
+
{ label: 'Back', value: 'back' }
|
|
1314
|
+
],
|
|
1315
|
+
onSelect: async (option) => {
|
|
1316
|
+
if (option.value === 'back') { setScreen(sp.directPublic ? spReturn : 'sp-pick-existing'); return; }
|
|
1317
|
+
if (option.value === 'edit') { setScreen('sp-edit-menu'); return; }
|
|
1318
|
+
if (option.value === 'open') {
|
|
1319
|
+
setResultScreen({
|
|
1320
|
+
title: page.title || 'Status page',
|
|
1321
|
+
lines: [hyperlink(page.published ? liveUrl : manageUrl, '↗ Open it in Rootly'), '', 'Customize and publish it in the web app.'],
|
|
1322
|
+
next: spReturn
|
|
1323
|
+
});
|
|
1324
|
+
setScreen('result');
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
setLoading(true);
|
|
1328
|
+
const result = await publishStatusPageForTui({ id: page.id });
|
|
1329
|
+
setLoading(false);
|
|
1330
|
+
setResultScreen({
|
|
1331
|
+
title: result.ok ? 'Status page published' : 'Could not publish',
|
|
1332
|
+
lines: result.ok
|
|
1333
|
+
? [`${page.title} is now live.`, '', hyperlink(liveUrl, '↗ View your status page'), '', 'Customize it anytime in the web app.']
|
|
1334
|
+
: [friendlyError(result.summary)],
|
|
1335
|
+
next: result.ok ? spReturn : 'sp-existing-actions'
|
|
1336
|
+
});
|
|
1337
|
+
setScreen('result');
|
|
1338
|
+
},
|
|
1339
|
+
onBack: () => setScreen(sp.directPublic ? spReturn : 'sp-pick-existing')
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (screen === 'sp-edit-menu') {
|
|
1344
|
+
const page = sp.existingPage || {};
|
|
1345
|
+
const slug = page.slug;
|
|
1346
|
+
const liveUrl = slug ? spViewUrl(slug, page.public) : STATUS_PAGES_URL;
|
|
1347
|
+
const manageUrl = slug ? spManageUrl(slug) : STATUS_PAGES_URL;
|
|
1348
|
+
const editError = (result) => {
|
|
1349
|
+
setResultScreen({ title: 'Update failed', lines: [friendlyError(result.summary)], next: 'sp-edit-menu' });
|
|
1350
|
+
setScreen('result');
|
|
1351
|
+
};
|
|
1352
|
+
return h(OptionScreen, {
|
|
1353
|
+
title: `Edit ${page.title || 'status page'}`,
|
|
1354
|
+
lines: [`${page.public ? 'Public' : 'Internal'} · ${page.published ? 'published' : 'draft'}.`, '', 'Change one setting at a time — each is saved immediately.'],
|
|
1355
|
+
options: [
|
|
1356
|
+
{ label: 'Rename', value: 'name' },
|
|
1357
|
+
{ label: 'Authentication', value: 'auth' },
|
|
1358
|
+
{ label: 'Components to show', value: 'components' },
|
|
1359
|
+
{ label: 'Website link', value: 'website' },
|
|
1360
|
+
{ label: page.published ? 'Unpublish (make it a draft)' : 'Publish now', value: 'toggle' },
|
|
1361
|
+
{ label: 'Done', value: 'done' }
|
|
1362
|
+
],
|
|
1363
|
+
onSelect: async (option) => {
|
|
1364
|
+
if (option.value === 'done') {
|
|
1365
|
+
setResultScreen({
|
|
1366
|
+
title: 'Status page updated',
|
|
1367
|
+
lines: [
|
|
1368
|
+
`${page.title}`,
|
|
1369
|
+
`${page.public ? 'Public' : 'Internal'} · ${page.published ? 'published' : 'draft'}`,
|
|
1370
|
+
'',
|
|
1371
|
+
hyperlink(page.published ? liveUrl : manageUrl, page.published ? '↗ View your status page' : '↗ Open it in Rootly')
|
|
1372
|
+
],
|
|
1373
|
+
next: spReturn
|
|
1374
|
+
});
|
|
1375
|
+
setScreen('result');
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (option.value === 'toggle') {
|
|
1379
|
+
setLoading(true);
|
|
1380
|
+
const result = await updateStatusPageForTui({ id: page.id, publish: !page.published });
|
|
1381
|
+
setLoading(false);
|
|
1382
|
+
if (result.ok) patchSp({ existingPage: { ...page, published: result.data.published, slug: result.data.slug || page.slug } });
|
|
1383
|
+
else editError(result);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
setScreen(`sp-edit-${option.value}`);
|
|
1387
|
+
},
|
|
1388
|
+
onBack: () => setScreen(sp.directPublic ? spReturn : 'sp-existing-actions')
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (screen === 'sp-edit-name') {
|
|
1393
|
+
const page = sp.existingPage || {};
|
|
1394
|
+
return h(TextEntryScreen, {
|
|
1395
|
+
title: 'Rename status page',
|
|
1396
|
+
prompt: 'New name for the page.',
|
|
1397
|
+
initialValue: page.title || '',
|
|
1398
|
+
onSubmit: async (value) => {
|
|
1399
|
+
setLoading(true);
|
|
1400
|
+
const result = await updateStatusPageForTui({ id: page.id, title: value });
|
|
1401
|
+
setLoading(false);
|
|
1402
|
+
if (result.ok) patchSp({ existingPage: { ...page, title: result.data.title || value, slug: result.data.slug || page.slug } });
|
|
1403
|
+
setScreen('sp-edit-menu');
|
|
1404
|
+
},
|
|
1405
|
+
onBack: () => setScreen('sp-edit-menu')
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (screen === 'sp-edit-auth') {
|
|
1410
|
+
const page = sp.existingPage || {};
|
|
1411
|
+
return h(OptionScreen, {
|
|
1412
|
+
title: 'Page authentication',
|
|
1413
|
+
lines: ['Who can view the page?'],
|
|
1414
|
+
options: [
|
|
1415
|
+
{ label: 'No authentication', value: 'none' },
|
|
1416
|
+
{ label: 'Password protect', value: 'password' },
|
|
1417
|
+
{ label: 'Back', value: 'back' }
|
|
1418
|
+
],
|
|
1419
|
+
onSelect: async (option) => {
|
|
1420
|
+
if (option.value === 'back') { setScreen('sp-edit-menu'); return; }
|
|
1421
|
+
if (option.value === 'password') { setScreen('sp-edit-password'); return; }
|
|
1422
|
+
setLoading(true);
|
|
1423
|
+
await updateStatusPageForTui({ id: page.id, authenticationMethod: 'none' });
|
|
1424
|
+
setLoading(false);
|
|
1425
|
+
setScreen('sp-edit-menu');
|
|
1426
|
+
},
|
|
1427
|
+
onBack: () => setScreen('sp-edit-menu')
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (screen === 'sp-edit-password') {
|
|
1432
|
+
const page = sp.existingPage || {};
|
|
1433
|
+
return h(TextEntryScreen, {
|
|
1434
|
+
title: 'Set a page password',
|
|
1435
|
+
prompt: 'Visitors will need this password to view the page.',
|
|
1436
|
+
placeholder: 'Password',
|
|
1437
|
+
hidden: true,
|
|
1438
|
+
onSubmit: async (value) => {
|
|
1439
|
+
setLoading(true);
|
|
1440
|
+
await updateStatusPageForTui({ id: page.id, authenticationMethod: 'password', authenticationPassword: value });
|
|
1441
|
+
setLoading(false);
|
|
1442
|
+
setScreen('sp-edit-menu');
|
|
1443
|
+
},
|
|
1444
|
+
onBack: () => setScreen('sp-edit-auth')
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (screen === 'sp-edit-components') {
|
|
1449
|
+
const page = sp.existingPage || {};
|
|
1450
|
+
const options = spComponents?.components || [];
|
|
1451
|
+
if (!options.length) {
|
|
1452
|
+
return h(OptionScreen, {
|
|
1453
|
+
title: 'Components to show',
|
|
1454
|
+
lines: ['No services or functionalities exist yet to show on the page.', '', 'Add some in the Rootly web app, then come back.'],
|
|
1455
|
+
options: [{ label: 'Back', value: 'back' }],
|
|
1456
|
+
onSelect: () => setScreen('sp-edit-menu'),
|
|
1457
|
+
onBack: () => setScreen('sp-edit-menu')
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
return h(MultiSelectScreen, {
|
|
1461
|
+
title: 'Components to show on the page',
|
|
1462
|
+
options,
|
|
1463
|
+
onSubmit: async (selected) => {
|
|
1464
|
+
setLoading(true);
|
|
1465
|
+
await updateStatusPageForTui({
|
|
1466
|
+
id: page.id,
|
|
1467
|
+
serviceIds: selected.filter((o) => o.value.startsWith('service:')).map((o) => o.value.slice(8)),
|
|
1468
|
+
functionalityIds: selected.filter((o) => o.value.startsWith('functionality:')).map((o) => o.value.slice(14))
|
|
1469
|
+
});
|
|
1470
|
+
setLoading(false);
|
|
1471
|
+
setScreen('sp-edit-menu');
|
|
1472
|
+
},
|
|
1473
|
+
onBack: () => setScreen('sp-edit-menu')
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (screen === 'sp-edit-website') {
|
|
1478
|
+
const page = sp.existingPage || {};
|
|
1479
|
+
return h(TextEntryScreen, {
|
|
1480
|
+
title: 'Website link',
|
|
1481
|
+
prompt: 'Link back to your main site (leave blank to clear it).',
|
|
1482
|
+
placeholder: 'https://yourcompany.com',
|
|
1483
|
+
allowEmpty: true,
|
|
1484
|
+
onSubmit: async (value) => {
|
|
1485
|
+
setLoading(true);
|
|
1486
|
+
await updateStatusPageForTui({ id: page.id, websiteUrl: value });
|
|
1487
|
+
setLoading(false);
|
|
1488
|
+
setScreen('sp-edit-menu');
|
|
1489
|
+
},
|
|
1490
|
+
onBack: () => setScreen('sp-edit-menu')
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
if (screen === 'sp-name') {
|
|
1495
|
+
const orgName = state?.teams?.workspace?.name;
|
|
1496
|
+
const defaultName = orgName ? `${orgName} Status` : 'Status Page';
|
|
1497
|
+
return h(TextEntryScreen, {
|
|
1498
|
+
title: sp.editId ? 'Set up your public status page' : 'Create a status page',
|
|
1499
|
+
prompt: sp.editId
|
|
1500
|
+
? 'Confirm or change the name of your status page.'
|
|
1501
|
+
: `Name your ${sp.isPublic === false ? 'internal' : 'public'} status page.`,
|
|
1502
|
+
placeholder: 'e.g. Acme Status',
|
|
1503
|
+
initialValue: sp.title || defaultName,
|
|
1504
|
+
onSubmit: (value) => {
|
|
1505
|
+
patchSp({ title: value });
|
|
1506
|
+
setScreen('sp-auth');
|
|
1507
|
+
},
|
|
1508
|
+
onBack: () => setScreen(sp.directPublic ? spReturn : 'sp-start')
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (screen === 'sp-auth') {
|
|
1513
|
+
return h(OptionScreen, {
|
|
1514
|
+
title: 'Page authentication',
|
|
1515
|
+
lines: ['Who can view the page?', '', 'Default is open — anyone with the link.'],
|
|
1516
|
+
options: [
|
|
1517
|
+
{ label: 'No authentication (default)', value: 'none' },
|
|
1518
|
+
{ label: 'Password protect', value: 'password' },
|
|
1519
|
+
{ label: 'Back', value: 'back' }
|
|
1520
|
+
],
|
|
1521
|
+
onSelect: (option) => {
|
|
1522
|
+
if (option.value === 'back') { setScreen('sp-name'); return; }
|
|
1523
|
+
patchSp({ authMethod: option.value });
|
|
1524
|
+
setScreen(option.value === 'password' ? 'sp-password' : 'sp-components');
|
|
1525
|
+
},
|
|
1526
|
+
onBack: () => setScreen('sp-name')
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if (screen === 'sp-password') {
|
|
1531
|
+
return h(TextEntryScreen, {
|
|
1532
|
+
title: 'Set a page password',
|
|
1533
|
+
prompt: 'Visitors will need this password to view the page.',
|
|
1534
|
+
placeholder: 'Password',
|
|
1535
|
+
hidden: true,
|
|
1536
|
+
onSubmit: (value) => {
|
|
1537
|
+
patchSp({ authPassword: value });
|
|
1538
|
+
setScreen('sp-components');
|
|
1539
|
+
},
|
|
1540
|
+
onBack: () => setScreen('sp-auth')
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (screen === 'sp-components') {
|
|
1545
|
+
// Map selected ids back to labels so we can show what's been added, each
|
|
1546
|
+
// with a green check.
|
|
1547
|
+
const componentLabel = new Map((spComponents?.components || []).map((c) => [c.value, c.label]));
|
|
1548
|
+
const addedValues = [
|
|
1549
|
+
...(sp.serviceIds || []).map((id) => `service:${id}`),
|
|
1550
|
+
...(sp.functionalityIds || []).map((id) => `functionality:${id}`)
|
|
1551
|
+
];
|
|
1552
|
+
const count = addedValues.length;
|
|
1553
|
+
const lines = count
|
|
1554
|
+
? [
|
|
1555
|
+
'Added components:',
|
|
1556
|
+
'',
|
|
1557
|
+
...addedValues.map((value) => ({ text: `✓ ${componentLabel.get(value) || value}`, color: palette.success })),
|
|
1558
|
+
'',
|
|
1559
|
+
'Add more, or publish your page.'
|
|
1560
|
+
]
|
|
1561
|
+
: ['Components let visitors see the status of your services.', '', 'Add some now, or just publish — you can add more later.'];
|
|
1562
|
+
return h(OptionScreen, {
|
|
1563
|
+
title: 'Components to show on the page',
|
|
1564
|
+
lines,
|
|
1565
|
+
options: [
|
|
1566
|
+
{ label: 'Go to publish', value: 'publish' },
|
|
1567
|
+
{ label: count ? 'Add or remove components' : 'Add a component', value: 'add' },
|
|
1568
|
+
{ label: 'Create your own component', value: 'custom' },
|
|
1569
|
+
{ label: 'Back', value: 'back' }
|
|
1570
|
+
],
|
|
1571
|
+
onSelect: async (option) => {
|
|
1572
|
+
if (option.value === 'back') { setScreen('sp-auth'); return; }
|
|
1573
|
+
if (option.value === 'add') { setScreen('sp-components-pick'); return; }
|
|
1574
|
+
if (option.value === 'custom') { setScreen('sp-component-custom'); return; }
|
|
1575
|
+
await finalizeStatusPage();
|
|
1576
|
+
},
|
|
1577
|
+
onBack: () => setScreen('sp-auth')
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (screen === 'sp-component-custom') {
|
|
1582
|
+
return h(TextEntryScreen, {
|
|
1583
|
+
title: 'Create your own component',
|
|
1584
|
+
prompt: 'Name a component to show on your status page (e.g. API, Dashboard, Checkout).',
|
|
1585
|
+
placeholder: 'e.g. API',
|
|
1586
|
+
onSubmit: async (value) => {
|
|
1587
|
+
const name = String(value || '').trim();
|
|
1588
|
+
if (!name) { setScreen('sp-components'); return; }
|
|
1589
|
+
setLoading(true);
|
|
1590
|
+
const result = await createCustomComponentForTui({ name });
|
|
1591
|
+
setLoading(false);
|
|
1592
|
+
if (result.ok) {
|
|
1593
|
+
// The custom component is a Service — add its id to serviceIds and to
|
|
1594
|
+
// the component list so it shows alongside the existing components.
|
|
1595
|
+
patchSp({ serviceIds: [...(sp.serviceIds || []), result.data.id], componentsTouched: true });
|
|
1596
|
+
setSpComponents((prev) => ({ components: [...(prev?.components || []), result.data.component] }));
|
|
1597
|
+
setScreen('sp-components');
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
setResultScreen({
|
|
1601
|
+
title: 'Component needs attention',
|
|
1602
|
+
lines: [friendlyError(result.summary)],
|
|
1603
|
+
next: 'sp-components'
|
|
1604
|
+
});
|
|
1605
|
+
setScreen('result');
|
|
1606
|
+
},
|
|
1607
|
+
onBack: () => setScreen('sp-components')
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
if (screen === 'sp-components-pick') {
|
|
1612
|
+
const options = spComponents?.components || [];
|
|
1613
|
+
if (!options.length) {
|
|
1614
|
+
return h(OptionScreen, {
|
|
1615
|
+
title: 'Add a component',
|
|
1616
|
+
lines: ['No services or functionalities exist yet to show on the page.', '', 'You can add components later in the Rootly web app.'],
|
|
1617
|
+
options: [{ label: 'Back', value: 'back' }],
|
|
1618
|
+
onSelect: () => setScreen('sp-components'),
|
|
1619
|
+
onBack: () => setScreen('sp-components')
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
return h(MultiSelectScreen, {
|
|
1623
|
+
title: 'Toggle components to show on the page (checked = added)',
|
|
1624
|
+
options,
|
|
1625
|
+
// Pre-check what's already on the page so the user can uncheck to remove
|
|
1626
|
+
// and check to add — submitting applies the full set.
|
|
1627
|
+
initialSelectedValues: [
|
|
1628
|
+
...(sp.serviceIds || []).map((id) => `service:${id}`),
|
|
1629
|
+
...(sp.functionalityIds || []).map((id) => `functionality:${id}`)
|
|
1630
|
+
],
|
|
1631
|
+
onSubmit: (selected) => {
|
|
1632
|
+
patchSp({
|
|
1633
|
+
serviceIds: selected.filter((o) => o.value.startsWith('service:')).map((o) => o.value.slice(8)),
|
|
1634
|
+
functionalityIds: selected.filter((o) => o.value.startsWith('functionality:')).map((o) => o.value.slice(14)),
|
|
1635
|
+
componentsTouched: true
|
|
1636
|
+
});
|
|
1637
|
+
setScreen('sp-components');
|
|
1638
|
+
},
|
|
1639
|
+
onBack: () => setScreen('sp-components')
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
if (screen === 'team-picker') {
|
|
1644
|
+
return h(OptionScreen, {
|
|
1645
|
+
title: 'Select a team',
|
|
1646
|
+
lines: ['Choose the team for the next step.'],
|
|
1647
|
+
options: [
|
|
1648
|
+
...((state?.teams?.all || []).map((team) => ({
|
|
1649
|
+
label: `${team.name} (${team.memberCount || 0} members, ${team.scheduleCount || 0} schedules, ${team.escalationPolicyCount || 0} escalations)`,
|
|
1650
|
+
value: team.id
|
|
1651
|
+
}))),
|
|
1652
|
+
{ label: 'Back', value: 'back' }
|
|
1653
|
+
],
|
|
1654
|
+
onSelect: (option) => {
|
|
1655
|
+
if (option.value === 'back') {
|
|
1656
|
+
setScreen('menu');
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
const team = (state?.teams?.all || []).find((entry) => entry.id === option.value) || null;
|
|
1660
|
+
setSelectedTeam(team);
|
|
1661
|
+
const next = formState.pendingAction;
|
|
1662
|
+
setScreen(next || 'menu');
|
|
1663
|
+
},
|
|
1664
|
+
onBack: () => setScreen('menu')
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
if (screen === 'add-members-picker') {
|
|
1669
|
+
const options = (addableUsers?.addable || []).map((user) => ({
|
|
1670
|
+
label: user.name || user.email || user.id,
|
|
1671
|
+
value: user.id
|
|
1672
|
+
}));
|
|
1673
|
+
|
|
1674
|
+
if (!options.length) {
|
|
1675
|
+
const lookupUnavailable = addableUsers?.userLookupUnavailable;
|
|
1676
|
+
return h(OptionScreen, {
|
|
1677
|
+
title: 'Add team members',
|
|
1678
|
+
lines: lookupUnavailable
|
|
1679
|
+
? [
|
|
1680
|
+
'This sign-in can’t list Rootly users (the user directory needs an API key).',
|
|
1681
|
+
'Invite someone by email instead.'
|
|
1682
|
+
]
|
|
1683
|
+
: [
|
|
1684
|
+
selectedTeam
|
|
1685
|
+
? `Everyone in this workspace is already on ${selectedTeam.name}, or there are no other users to add.`
|
|
1686
|
+
: 'No users available to add.',
|
|
1687
|
+
'You can still invite someone by email.'
|
|
1688
|
+
],
|
|
1689
|
+
options: [
|
|
1690
|
+
{ label: 'Invite by email instead', value: 'email' },
|
|
1691
|
+
{ label: 'Back', value: 'back' }
|
|
1692
|
+
],
|
|
1693
|
+
onSelect: (option) => {
|
|
1694
|
+
if (option.value === 'email') setScreen('add-team-members');
|
|
1695
|
+
else setScreen('general-menu');
|
|
1696
|
+
},
|
|
1697
|
+
onBack: () => setScreen('general-menu')
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Only one user available (e.g. an OAuth session that can only see the
|
|
1702
|
+
// signed-in user) — confirm-and-add rather than a 1-item check/uncheck.
|
|
1703
|
+
if (options.length === 1) {
|
|
1704
|
+
const onlyUser = options[0];
|
|
1705
|
+
return h(OptionScreen, {
|
|
1706
|
+
title: selectedTeam ? `Add members to ${selectedTeam.name}` : 'Add team members',
|
|
1707
|
+
lines: [`${onlyUser.label} is the only user available to add.`],
|
|
1708
|
+
options: [
|
|
1709
|
+
{ label: `Add ${onlyUser.label}`, value: 'add' },
|
|
1710
|
+
{ label: 'Invite someone else by email', value: 'email' },
|
|
1711
|
+
{ label: 'Back', value: 'back' }
|
|
1712
|
+
],
|
|
1713
|
+
onSelect: async (option) => {
|
|
1714
|
+
if (option.value === 'back') { setScreen('general-menu'); return; }
|
|
1715
|
+
if (option.value === 'email') { setScreen('add-team-members'); return; }
|
|
1716
|
+
setLoading(true);
|
|
1717
|
+
const result = await addTeamMembersByIdsForTui({ teamId: selectedTeam?.id, userIds: [onlyUser.value] });
|
|
1718
|
+
setLoading(false);
|
|
1719
|
+
setResultScreen({
|
|
1720
|
+
title: result.ok ? 'Team members added' : 'Team members need attention',
|
|
1721
|
+
lines: result.ok
|
|
1722
|
+
? [`Team: ${selectedTeam?.name || 'Unknown'}`, `Added as members: ${onlyUser.label}`]
|
|
1723
|
+
: [friendlyError(result.summary)],
|
|
1724
|
+
next: 'menu'
|
|
1725
|
+
});
|
|
1726
|
+
setScreen('result');
|
|
1727
|
+
},
|
|
1728
|
+
onBack: () => setScreen('general-menu')
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
return h(MultiSelectScreen, {
|
|
1733
|
+
title: selectedTeam ? `Add members to ${selectedTeam.name}` : 'Add team members',
|
|
1734
|
+
options,
|
|
1735
|
+
onSubmit: async (selectedOptions) => {
|
|
1736
|
+
if (!selectedOptions.length) {
|
|
1737
|
+
setScreen('menu');
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
setLoading(true);
|
|
1741
|
+
const result = await addTeamMembersByIdsForTui({
|
|
1742
|
+
teamId: selectedTeam?.id,
|
|
1743
|
+
userIds: selectedOptions.map((option) => option.value)
|
|
1744
|
+
});
|
|
1745
|
+
setLoading(false);
|
|
1746
|
+
setResultScreen({
|
|
1747
|
+
title: result.ok ? 'Team members added' : 'Team members need attention',
|
|
1748
|
+
lines: result.ok
|
|
1749
|
+
? [
|
|
1750
|
+
`Team: ${selectedTeam?.name || 'Unknown'}`,
|
|
1751
|
+
`Added as members: ${selectedOptions.map((option) => option.label).join(', ')}`
|
|
1752
|
+
]
|
|
1753
|
+
: [friendlyError(result.summary)],
|
|
1754
|
+
next: 'menu'
|
|
1755
|
+
});
|
|
1756
|
+
setScreen('result');
|
|
1757
|
+
},
|
|
1758
|
+
onBack: () => setScreen('general-menu')
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if (screen === 'add-team-members') {
|
|
1763
|
+
return h(TextEntryScreen, {
|
|
1764
|
+
title: 'Add team members (by email)',
|
|
1765
|
+
prompt: selectedTeam ? `Comma-separated emails for ${selectedTeam.name}` : 'Comma-separated emails',
|
|
1766
|
+
placeholder: 'alice@example.com, bob@example.com',
|
|
1767
|
+
onSubmit: async (value) => {
|
|
1768
|
+
setLoading(true);
|
|
1769
|
+
const emails = value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
1770
|
+
const result = await addTeamMembersForTui({ teamId: selectedTeam?.id, emails });
|
|
1771
|
+
setLoading(false);
|
|
1772
|
+
const memberNames = (result.data?.matchedUsers || [])
|
|
1773
|
+
.map((user) => user.name || user.email)
|
|
1774
|
+
.filter(Boolean)
|
|
1775
|
+
.join(', ');
|
|
1776
|
+
setResultScreen({
|
|
1777
|
+
title: result.ok ? 'Team members updated' : 'Team members need attention',
|
|
1778
|
+
lines: result.ok
|
|
1779
|
+
? [
|
|
1780
|
+
`Team: ${selectedTeam?.name || 'Unknown'}`,
|
|
1781
|
+
result.data?.userLookupUnavailable
|
|
1782
|
+
? 'Could not resolve Rootly users; emails attached as team contacts.'
|
|
1783
|
+
: `Added as members: ${memberNames || 'no new members'}`,
|
|
1784
|
+
...(result.data?.unresolvedEmails?.length
|
|
1785
|
+
? [`Not in Rootly yet (added as contacts): ${result.data.unresolvedEmails.join(', ')}`]
|
|
1786
|
+
: [])
|
|
1787
|
+
]
|
|
1788
|
+
: [friendlyError(result.summary)],
|
|
1789
|
+
next: 'menu'
|
|
1790
|
+
});
|
|
1791
|
+
setScreen('result');
|
|
1792
|
+
},
|
|
1793
|
+
onBack: () => setScreen('menu')
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
if (screen === 'create-schedule-name') {
|
|
1798
|
+
return h(TextEntryScreen, {
|
|
1799
|
+
title: 'Create a schedule',
|
|
1800
|
+
prompt: selectedTeam ? `Schedule name for ${selectedTeam.name}` : 'Schedule name',
|
|
1801
|
+
initialValue: selectedTeam ? `${selectedTeam.name} On-Call` : '',
|
|
1802
|
+
onSubmit: (value) => {
|
|
1803
|
+
setFormState((current) => ({ ...current, scheduleName: value || `${selectedTeam?.name || 'Team'} On-Call` }));
|
|
1804
|
+
setScreen('create-schedule-handoff');
|
|
1805
|
+
},
|
|
1806
|
+
onBack: () => setScreen('menu')
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
if (screen === 'create-schedule-handoff') {
|
|
1811
|
+
return h(TextEntryScreen, {
|
|
1812
|
+
title: 'Daily handoff time',
|
|
1813
|
+
prompt: 'HH:MM (workspace timezone)',
|
|
1814
|
+
initialValue: '09:00',
|
|
1815
|
+
onSubmit: (value) => {
|
|
1816
|
+
setFormState((current) => ({ ...current, handoffTime: value || '09:00' }));
|
|
1817
|
+
setScreen('schedule-members');
|
|
1818
|
+
},
|
|
1819
|
+
onBack: () => setScreen('create-schedule-name')
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if (screen === 'schedule-members') {
|
|
1824
|
+
const memberOptions = (teamMembersData?.members || [])
|
|
1825
|
+
.filter((member) => !member.serviceAccount)
|
|
1826
|
+
.map((member) => ({
|
|
1827
|
+
label: member.name || member.email || member.id,
|
|
1828
|
+
value: member.id
|
|
1829
|
+
}));
|
|
1830
|
+
|
|
1831
|
+
if (!memberOptions.length) {
|
|
1832
|
+
return h(OptionScreen, {
|
|
1833
|
+
title: 'On-call rotation',
|
|
1834
|
+
lines: [
|
|
1835
|
+
selectedTeam ? `${selectedTeam.name} has no members to staff a rotation yet.` : 'This team has no members to staff a rotation yet.',
|
|
1836
|
+
'The schedule will be created without a staffed rotation.'
|
|
1837
|
+
],
|
|
1838
|
+
options: [
|
|
1839
|
+
{ label: 'Create schedule anyway', value: 'continue' },
|
|
1840
|
+
{ label: 'Back', value: 'back' }
|
|
1841
|
+
],
|
|
1842
|
+
onSelect: async (option) => {
|
|
1843
|
+
if (option.value === 'back') {
|
|
1844
|
+
setScreen('create-schedule-handoff');
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
setLoading(true);
|
|
1848
|
+
const result = await createScheduleForTui({
|
|
1849
|
+
teamId: selectedTeam?.id,
|
|
1850
|
+
name: formState.scheduleName,
|
|
1851
|
+
handoffTime: formState.handoffTime || '09:00',
|
|
1852
|
+
memberIds: []
|
|
1853
|
+
});
|
|
1854
|
+
setLoading(false);
|
|
1855
|
+
setResultScreen({
|
|
1856
|
+
title: result.ok ? 'Schedule created' : 'Schedule setup needs attention',
|
|
1857
|
+
lines: result.ok
|
|
1858
|
+
? [`Team: ${selectedTeam?.name || 'Unknown'}`, `Schedule: ${formState.scheduleName}`, 'No one is on the initial rotation yet.']
|
|
1859
|
+
: [friendlyError(result.summary)],
|
|
1860
|
+
next: 'menu'
|
|
1861
|
+
});
|
|
1862
|
+
setScreen('result');
|
|
1863
|
+
},
|
|
1864
|
+
onBack: () => setScreen('create-schedule-handoff')
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
return h(MultiSelectScreen, {
|
|
1869
|
+
title: 'Who should be on the on-call rotation?',
|
|
1870
|
+
options: memberOptions,
|
|
1871
|
+
onSubmit: async (selectedOptions) => {
|
|
1872
|
+
setLoading(true);
|
|
1873
|
+
const result = await createScheduleForTui({
|
|
1874
|
+
teamId: selectedTeam?.id,
|
|
1875
|
+
name: formState.scheduleName,
|
|
1876
|
+
handoffTime: formState.handoffTime || '09:00',
|
|
1877
|
+
memberIds: selectedOptions.map((option) => option.value)
|
|
1878
|
+
});
|
|
1879
|
+
setLoading(false);
|
|
1880
|
+
setResultScreen({
|
|
1881
|
+
title: result.ok ? 'Schedule created' : 'Schedule setup needs attention',
|
|
1882
|
+
lines: result.ok
|
|
1883
|
+
? [
|
|
1884
|
+
`Team: ${selectedTeam?.name || 'Unknown'}`,
|
|
1885
|
+
`Schedule: ${formState.scheduleName}`,
|
|
1886
|
+
`Rotation members: ${selectedOptions.map((option) => option.label).join(', ') || 'none'}`
|
|
1887
|
+
]
|
|
1888
|
+
: [friendlyError(result.summary)],
|
|
1889
|
+
next: 'menu'
|
|
1890
|
+
});
|
|
1891
|
+
setScreen('result');
|
|
1892
|
+
},
|
|
1893
|
+
onBack: () => setScreen('create-schedule-handoff')
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
if (screen === 'create-escalation') {
|
|
1898
|
+
return h(TextEntryScreen, {
|
|
1899
|
+
title: 'Create an escalation policy',
|
|
1900
|
+
prompt: selectedTeam ? `Policy name for ${selectedTeam.name}` : 'Policy name',
|
|
1901
|
+
initialValue: selectedTeam ? `${selectedTeam.name} Default Escalation` : '',
|
|
1902
|
+
onSubmit: async (value) => {
|
|
1903
|
+
setLoading(true);
|
|
1904
|
+
const result = await createEscalationPolicyForTui({
|
|
1905
|
+
teamId: selectedTeam?.id,
|
|
1906
|
+
name: value || `${selectedTeam?.name || 'Team'} Default Escalation`,
|
|
1907
|
+
repeatCount: 1
|
|
1908
|
+
});
|
|
1909
|
+
setLoading(false);
|
|
1910
|
+
setResultScreen({
|
|
1911
|
+
title: result.ok ? 'Escalation policy created' : 'Escalation policy needs attention',
|
|
1912
|
+
lines: result.ok
|
|
1913
|
+
? [`Team: ${selectedTeam?.name || 'Unknown'}`, `Policy: ${result.data?.name || value}`]
|
|
1914
|
+
: [friendlyError(result.summary)],
|
|
1915
|
+
next: 'menu'
|
|
1916
|
+
});
|
|
1917
|
+
setScreen('result');
|
|
1918
|
+
},
|
|
1919
|
+
onBack: () => setScreen('menu')
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (screen === 'create-alert-source') {
|
|
1924
|
+
return h(TextEntryScreen, {
|
|
1925
|
+
title: 'Connect an alert source',
|
|
1926
|
+
prompt: selectedTeam ? `Alert source name for ${selectedTeam.name}` : 'Alert source name',
|
|
1927
|
+
initialValue: selectedTeam ? `${selectedTeam.name} Generic Webhook` : 'Generic webhook',
|
|
1928
|
+
onSubmit: async (value) => {
|
|
1929
|
+
setLoading(true);
|
|
1930
|
+
const result = await createAlertSourceForTui({
|
|
1931
|
+
teamId: selectedTeam?.id,
|
|
1932
|
+
name: value || 'Generic webhook'
|
|
1933
|
+
});
|
|
1934
|
+
setLoading(false);
|
|
1935
|
+
setResultScreen({
|
|
1936
|
+
title: result.ok ? 'Alert source created' : 'Alert source needs attention',
|
|
1937
|
+
lines: result.ok
|
|
1938
|
+
? [`Source: ${result.data?.name || value}`, result.data?.webhookEndpoint ? `Webhook: ${result.data.webhookEndpoint}` : 'Webhook created']
|
|
1939
|
+
: [friendlyError(result.summary)],
|
|
1940
|
+
next: 'menu'
|
|
1941
|
+
});
|
|
1942
|
+
setScreen('result');
|
|
1943
|
+
},
|
|
1944
|
+
onBack: () => setScreen('menu')
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
if (screen === 'create-test-alert') {
|
|
1949
|
+
return h(TextEntryScreen, {
|
|
1950
|
+
title: 'Send a test alert',
|
|
1951
|
+
prompt: selectedTeam ? `Alert summary for ${selectedTeam.name}` : 'Alert summary',
|
|
1952
|
+
initialValue: 'Rootly Wizard test alert',
|
|
1953
|
+
onSubmit: async (value) => {
|
|
1954
|
+
setLoading(true);
|
|
1955
|
+
const result = await createTestAlertForTui({
|
|
1956
|
+
summary: value || 'Rootly Wizard test alert',
|
|
1957
|
+
groupIds: selectedTeam?.id ? [selectedTeam.id] : [],
|
|
1958
|
+
// Page the team's on-call escalation policy so the alert actually rings.
|
|
1959
|
+
page: true
|
|
1960
|
+
});
|
|
1961
|
+
setLoading(false);
|
|
1962
|
+
setResultScreen({
|
|
1963
|
+
title: result.ok ? 'Test alert sent' : 'Test alert needs attention',
|
|
1964
|
+
lines: result.ok
|
|
1965
|
+
? [
|
|
1966
|
+
`Alert: ${result.data?.summary || value}`,
|
|
1967
|
+
result.data?.paged
|
|
1968
|
+
? 'Paging the on-call person now — watch for a call or text.'
|
|
1969
|
+
: 'Heads up: no escalation policy found for this team, so it won’t page anyone.'
|
|
1970
|
+
]
|
|
1971
|
+
: [friendlyError(result.summary)],
|
|
1972
|
+
next: 'menu'
|
|
1973
|
+
});
|
|
1974
|
+
setScreen('result');
|
|
1975
|
+
},
|
|
1976
|
+
onBack: () => setScreen('menu')
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
if (screen === 'create-test-incident') {
|
|
1981
|
+
return h(TextEntryScreen, {
|
|
1982
|
+
title: 'Create a test incident',
|
|
1983
|
+
prompt: selectedTeam ? `Incident title for ${selectedTeam.name}` : 'Incident title',
|
|
1984
|
+
initialValue: 'Rootly Wizard test incident',
|
|
1985
|
+
onSubmit: async (value) => {
|
|
1986
|
+
setLoading(true);
|
|
1987
|
+
const result = await createTestIncidentForTui({
|
|
1988
|
+
title: value || 'Rootly Wizard test incident',
|
|
1989
|
+
groupIds: selectedTeam?.id ? [selectedTeam.id] : []
|
|
1990
|
+
});
|
|
1991
|
+
setLoading(false);
|
|
1992
|
+
setResultScreen({
|
|
1993
|
+
title: result.ok ? 'Test incident created' : 'Test incident needs attention',
|
|
1994
|
+
lines: result.ok ? [`Incident: ${result.data?.title || value}`] : [friendlyError(result.summary)],
|
|
1995
|
+
next: 'menu'
|
|
1996
|
+
});
|
|
1997
|
+
setScreen('result');
|
|
1998
|
+
},
|
|
1999
|
+
onBack: () => setScreen('menu')
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
return h(MainMenuScreen, {
|
|
2004
|
+
state,
|
|
2005
|
+
onBack: (option) => {
|
|
2006
|
+
if (!option) {
|
|
2007
|
+
leave();
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (option.value === 'back') {
|
|
2011
|
+
leave();
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (option.value === 'exit') {
|
|
2015
|
+
leave();
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
if (option.value === 'status') {
|
|
2019
|
+
setScreen('status');
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (option.value === 'auth') {
|
|
2023
|
+
setScreen('auth-method');
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (option.value === 'inspect') {
|
|
2027
|
+
setScreen('inspect');
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (option.value === 'quickstart') {
|
|
2031
|
+
setScreen('one-shot');
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
if (option.value === 'create-team') {
|
|
2035
|
+
setScreen('create-team');
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
if (option.value === 'setup') {
|
|
2039
|
+
setScreen('setup-menu');
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
if (option.value === 'general') {
|
|
2043
|
+
setScreen('general-menu');
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
if (option.value === 'integrations') {
|
|
2047
|
+
setScreen('integrations-menu');
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
if (option.value === 'verify') {
|
|
2051
|
+
setScreen('verify-menu');
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (option.value === 'mcp') {
|
|
2055
|
+
setScreen('mcp-menu');
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
setScreen('placeholder');
|
|
2059
|
+
},
|
|
2060
|
+
onExit: () => setScreen('exit-confirm')
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
export async function startInteractiveWizard() {
|
|
2065
|
+
let closed = false;
|
|
2066
|
+
enterAltScreen();
|
|
2067
|
+
const app = render(h(InkWizardApp, { onExit: () => { closed = true; } }), {
|
|
2068
|
+
stdout: process.stdout,
|
|
2069
|
+
stdin: process.stdin,
|
|
2070
|
+
exitOnCtrlC: true
|
|
2071
|
+
});
|
|
2072
|
+
try {
|
|
2073
|
+
await app.waitUntilExit();
|
|
2074
|
+
return { closed };
|
|
2075
|
+
} finally {
|
|
2076
|
+
leaveAltScreen();
|
|
2077
|
+
}
|
|
2078
|
+
}
|