spinuptui 1.2.0-alpha.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -10
- package/README.md +5 -10
- package/package.json +1 -1
- package/src/api/client.ts +15 -0
- package/src/api/types.ts +3 -0
- package/src/config.ts +0 -23
- package/src/dev/mockClient.ts +30 -7
- package/src/index.tsx +17 -16
- package/src/lib/appUpdate.ts +7 -33
- package/src/lib/cliOffline.ts +418 -0
- package/src/lib/offline.test.ts +181 -0
- package/src/lib/offline.ts +422 -0
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/ui/App.tsx +12 -10
- package/src/ui/Details.tsx +8 -2
- package/src/ui/Explain.tsx +2 -15
- package/src/ui/Header.tsx +0 -2
- package/src/ui/Help.tsx +3 -2
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/components.tsx +2 -1
- package/src/ui/offlineRun.tsx +82 -0
- package/src/ui/store.tsx +76 -10
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +13 -5
- package/src/ui/views/Dashboard.tsx +3 -1
- package/src/ui/views/Search.tsx +8 -6
- package/src/ui/views/ServerActions.tsx +108 -11
- package/src/ui/views/SiteOffline.tsx +171 -0
- package/src/ui/views/Stacks.tsx +26 -9
- package/src/lib/appUpdate.test.ts +0 -26
- package/src/lib/beacon.test.ts +0 -140
- package/src/lib/beacon.ts +0 -349
- package/src/lib/beaconSetup.test.ts +0 -65
- package/src/lib/beaconSetup.ts +0 -143
- package/src/lib/cliBeacon.ts +0 -290
- package/src/lib/prompt.ts +0 -47
- package/src/modules/beacon.ts +0 -51
- package/src/modules/contract.ts +0 -61
- package/src/modules/index.ts +0 -34
- package/src/ui/views/BeaconSetup.tsx +0 -268
- package/src/ui/views/Modules.tsx +0 -196
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
// Beacon setup overlay, opened with ⏎ on Beacon in the Modules tab. The same
|
|
2
|
-
// guided flow as `spinuptui beacon setup` (lib/beaconSetup.ts): paste a bot
|
|
3
|
-
// token, message the bot so Beacon can find the chat, then a test page — and
|
|
4
|
-
// only then is anything saved.
|
|
5
|
-
|
|
6
|
-
import { useEffect, useRef, useState } from "react"
|
|
7
|
-
import { useKeyboard } from "@opentui/react"
|
|
8
|
-
import { theme } from "../../lib/theme.ts"
|
|
9
|
-
import { Panel, Centered, Spinner, SecretInput } from "../components.tsx"
|
|
10
|
-
import { StatusBar } from "../StatusBar.tsx"
|
|
11
|
-
import { useStore } from "../store.tsx"
|
|
12
|
-
import { openUrl } from "../../lib/open.ts"
|
|
13
|
-
import {
|
|
14
|
-
BOTFATHER_STEPS,
|
|
15
|
-
BOTFATHER_URL,
|
|
16
|
-
botLink,
|
|
17
|
-
channelExists,
|
|
18
|
-
discoverChat,
|
|
19
|
-
saveBeaconChannel,
|
|
20
|
-
sendConnectedMessage,
|
|
21
|
-
setupChannelName,
|
|
22
|
-
verifyBotToken,
|
|
23
|
-
type FoundChat,
|
|
24
|
-
} from "../../lib/beaconSetup.ts"
|
|
25
|
-
|
|
26
|
-
type Phase = "token" | "verifying" | "waiting" | "found" | "saving" | "done" | "error"
|
|
27
|
-
|
|
28
|
-
const WIDTH = 70
|
|
29
|
-
|
|
30
|
-
export function BeaconSetup() {
|
|
31
|
-
const { setBeaconSetupOpen, setInputMode } = useStore()
|
|
32
|
-
const [name] = useState(setupChannelName)
|
|
33
|
-
const [replacing] = useState(() => channelExists(name))
|
|
34
|
-
const [phase, setPhase] = useState<Phase>("token")
|
|
35
|
-
const [token, setToken] = useState("")
|
|
36
|
-
const [username, setUsername] = useState("")
|
|
37
|
-
const [chat, setChat] = useState<FoundChat | null>(null)
|
|
38
|
-
const [error, setError] = useState<string | null>(null)
|
|
39
|
-
const [retryFrom, setRetryFrom] = useState<Phase>("token")
|
|
40
|
-
const abort = useRef<AbortController | null>(null)
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
setInputMode(phase === "token")
|
|
44
|
-
return () => setInputMode(false)
|
|
45
|
-
}, [phase, setInputMode])
|
|
46
|
-
|
|
47
|
-
// Stop any long-poll when the overlay goes away.
|
|
48
|
-
useEffect(() => () => abort.current?.abort(), [])
|
|
49
|
-
|
|
50
|
-
const close = () => {
|
|
51
|
-
abort.current?.abort()
|
|
52
|
-
setInputMode(false)
|
|
53
|
-
setBeaconSetupOpen(false)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const fail = (message: string, from: Phase) => {
|
|
57
|
-
setError(message)
|
|
58
|
-
setRetryFrom(from)
|
|
59
|
-
setPhase("error")
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const listen = (tok: string) => {
|
|
63
|
-
abort.current?.abort()
|
|
64
|
-
const ctrl = new AbortController()
|
|
65
|
-
abort.current = ctrl
|
|
66
|
-
setChat(null)
|
|
67
|
-
setPhase("waiting")
|
|
68
|
-
void discoverChat(tok, { signal: ctrl.signal }).then((r) => {
|
|
69
|
-
if (ctrl.signal.aborted) return
|
|
70
|
-
if (r.ok) {
|
|
71
|
-
setChat(r.chat)
|
|
72
|
-
setPhase("found")
|
|
73
|
-
} else fail(r.message, "waiting")
|
|
74
|
-
})
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const submitToken = () => {
|
|
78
|
-
const tok = token.trim()
|
|
79
|
-
if (!tok) return
|
|
80
|
-
setPhase("verifying")
|
|
81
|
-
void verifyBotToken(tok).then((me) => {
|
|
82
|
-
if (!me.ok) return fail(me.error ?? "Telegram rejected the token.", "token")
|
|
83
|
-
setUsername(me.username ?? "")
|
|
84
|
-
listen(tok)
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const save = () => {
|
|
89
|
-
if (!chat) return
|
|
90
|
-
const channel = { type: "telegram" as const, botToken: token.trim(), chatId: chat.chatId }
|
|
91
|
-
setPhase("saving")
|
|
92
|
-
void sendConnectedMessage(name, channel).then(async (r) => {
|
|
93
|
-
if (!r.ok) return fail(`Couldn't send the test message: ${r.message}. Nothing was saved.`, "found")
|
|
94
|
-
await saveBeaconChannel(name, channel, false)
|
|
95
|
-
setToken("") // don't keep the secret in component state once it's saved
|
|
96
|
-
setPhase("done")
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
useKeyboard((key) => {
|
|
101
|
-
const k = key.name ?? ""
|
|
102
|
-
if (k === "escape") return close()
|
|
103
|
-
if (phase === "token") return // SecretInput owns typing; Enter submits
|
|
104
|
-
if (phase === "waiting") {
|
|
105
|
-
if (k === "o" && username) return openUrl(botLink(username))
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
if (phase === "found") {
|
|
109
|
-
if (k === "return") return save()
|
|
110
|
-
if (k === "r") return listen(token.trim())
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
if (phase === "error") {
|
|
114
|
-
if (k === "r") {
|
|
115
|
-
setError(null)
|
|
116
|
-
if (retryFrom === "waiting") return listen(token.trim())
|
|
117
|
-
if (retryFrom === "found" && chat) return setPhase("found")
|
|
118
|
-
setToken("") // a rejected token is replaced, not edited
|
|
119
|
-
return setPhase("token")
|
|
120
|
-
}
|
|
121
|
-
if (k === "q") return close()
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
if (phase === "done" && (k === "return" || k === "q")) return close()
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
<box
|
|
129
|
-
style={{
|
|
130
|
-
position: "absolute",
|
|
131
|
-
top: 0,
|
|
132
|
-
left: 0,
|
|
133
|
-
width: "100%",
|
|
134
|
-
height: "100%",
|
|
135
|
-
flexDirection: "column",
|
|
136
|
-
backgroundColor: theme.bg,
|
|
137
|
-
zIndex: 200,
|
|
138
|
-
}}
|
|
139
|
-
>
|
|
140
|
-
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1 }}>
|
|
141
|
-
<text content="Beacon " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
142
|
-
<text content="connect a Telegram pager" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
143
|
-
<box style={{ flexGrow: 1 }} />
|
|
144
|
-
<text content={`channel “${name}”`} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
145
|
-
</box>
|
|
146
|
-
<Centered>{body()}</Centered>
|
|
147
|
-
<StatusBar hints={hints()} showGlobal={false} />
|
|
148
|
-
</box>
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
function body() {
|
|
152
|
-
if (phase === "token") {
|
|
153
|
-
return (
|
|
154
|
-
<Panel title=" 1 · Your bot " active>
|
|
155
|
-
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
156
|
-
<text content="Beacon pages you through your own Telegram bot. Making one is free:" fg={theme.textDim} />
|
|
157
|
-
<box style={{ height: 1 }} />
|
|
158
|
-
{BOTFATHER_STEPS.map((s, i) => (
|
|
159
|
-
<text key={i} content={` ${i + 1}. ${s}`} fg={theme.text} />
|
|
160
|
-
))}
|
|
161
|
-
<box style={{ height: 1 }} />
|
|
162
|
-
<text content="Then paste the token here (it's hidden as you type):" fg={theme.textDim} />
|
|
163
|
-
<SecretInput focused value={token} placeholder="123456789:AAE…" onChange={setToken} onSubmit={submitToken} width={WIDTH - 2} />
|
|
164
|
-
{replacing && (
|
|
165
|
-
<>
|
|
166
|
-
<box style={{ height: 1 }} />
|
|
167
|
-
<text content={`⚠ This replaces the existing “${name}” channel once the new one is tested.`} fg={theme.warn} />
|
|
168
|
-
</>
|
|
169
|
-
)}
|
|
170
|
-
<box style={{ height: 1 }} />
|
|
171
|
-
<text content={`⏎ continue · Esc cancel · BotFather: ${BOTFATHER_URL}`} fg={theme.textFaint} wrapMode="none" />
|
|
172
|
-
</box>
|
|
173
|
-
</Panel>
|
|
174
|
-
)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (phase === "verifying" || phase === "saving") {
|
|
178
|
-
return (
|
|
179
|
-
<box style={{ flexDirection: "row" }}>
|
|
180
|
-
<Spinner />
|
|
181
|
-
<text content={phase === "verifying" ? " Checking the token with Telegram…" : " Sending a test message…"} fg={theme.textDim} />
|
|
182
|
-
</box>
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (phase === "waiting") {
|
|
187
|
-
return (
|
|
188
|
-
<Panel title=" 2 · Say hello to your bot " active>
|
|
189
|
-
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
190
|
-
<box style={{ flexDirection: "row" }}>
|
|
191
|
-
<text content="✓ Bot " fg={theme.good} wrapMode="none" />
|
|
192
|
-
<text content={username ? `@${username}` : "found"} fg={theme.accent} wrapMode="none" />
|
|
193
|
-
</box>
|
|
194
|
-
<box style={{ height: 1 }} />
|
|
195
|
-
<text content="Now open Telegram and send your bot any message, so Beacon knows which" fg={theme.text} />
|
|
196
|
-
<text content="chat to page." fg={theme.text} />
|
|
197
|
-
{username && <text content={` ${botLink(username)} (press o to open it)`} fg={theme.accent} wrapMode="none" />}
|
|
198
|
-
<box style={{ height: 1 }} />
|
|
199
|
-
<text content="To page a group instead, add the bot to the group and send /start there." fg={theme.textFaint} />
|
|
200
|
-
<box style={{ height: 1 }} />
|
|
201
|
-
<box style={{ flexDirection: "row" }}>
|
|
202
|
-
<Spinner />
|
|
203
|
-
<text content=" Waiting for your message (up to 5 minutes)…" fg={theme.textDim} />
|
|
204
|
-
</box>
|
|
205
|
-
</box>
|
|
206
|
-
</Panel>
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (phase === "found" && chat) {
|
|
211
|
-
return (
|
|
212
|
-
<Panel title=" 3 · Confirm the chat " active>
|
|
213
|
-
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
214
|
-
<box style={{ flexDirection: "row" }}>
|
|
215
|
-
<text content="✓ Message received from " fg={theme.good} wrapMode="none" />
|
|
216
|
-
<text content={chat.label} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
217
|
-
</box>
|
|
218
|
-
<text content={` a ${chat.kind} chat`} fg={theme.textFaint} />
|
|
219
|
-
<box style={{ height: 1 }} />
|
|
220
|
-
<text content="Beacon will page this chat. Press ⏎ to send a test message and save." fg={theme.text} />
|
|
221
|
-
<text content="Wrong chat? Press r, then message the bot from the right one." fg={theme.textDim} />
|
|
222
|
-
</box>
|
|
223
|
-
</Panel>
|
|
224
|
-
)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (phase === "done") {
|
|
228
|
-
return (
|
|
229
|
-
<Panel title=" Beacon connected " active>
|
|
230
|
-
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
231
|
-
<text content={`● Channel “${name}” is set up, and a test message is on its way.`} fg={theme.good} />
|
|
232
|
-
<box style={{ height: 1 }} />
|
|
233
|
-
<text content="Anything on this machine can page you now:" fg={theme.textDim} />
|
|
234
|
-
<text content={` spinuptui beacon send "web1 is down"`} fg={theme.accent} wrapMode="none" />
|
|
235
|
-
<text content={` some-report | spinuptui beacon send --silent`} fg={theme.accent} wrapMode="none" />
|
|
236
|
-
<box style={{ height: 1 }} />
|
|
237
|
-
<text content="⏎ done" fg={theme.textFaint} />
|
|
238
|
-
</box>
|
|
239
|
-
</Panel>
|
|
240
|
-
)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return (
|
|
244
|
-
<Panel title=" Not connected yet " active>
|
|
245
|
-
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
246
|
-
<text content={`✕ ${error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
247
|
-
<box style={{ height: 1 }} />
|
|
248
|
-
<text content="Nothing was saved. r — try again · Esc — close" fg={theme.textDim} />
|
|
249
|
-
</box>
|
|
250
|
-
</Panel>
|
|
251
|
-
)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function hints() {
|
|
255
|
-
if (phase === "token") return [{ key: "⏎", label: "continue" }, { key: "esc", label: "cancel" }]
|
|
256
|
-
if (phase === "waiting")
|
|
257
|
-
return [...(username ? [{ key: "o", label: "open the bot" }] : []), { key: "esc", label: "cancel" }]
|
|
258
|
-
if (phase === "found")
|
|
259
|
-
return [
|
|
260
|
-
{ key: "⏎", label: "test & save" },
|
|
261
|
-
{ key: "r", label: "different chat" },
|
|
262
|
-
{ key: "esc", label: "cancel" },
|
|
263
|
-
]
|
|
264
|
-
if (phase === "error") return [{ key: "r", label: "try again" }, { key: "esc", label: "close" }]
|
|
265
|
-
if (phase === "done") return [{ key: "⏎", label: "done" }]
|
|
266
|
-
return [{ key: "esc", label: "cancel" }]
|
|
267
|
-
}
|
|
268
|
-
}
|
package/src/ui/views/Modules.tsx
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
// Modules tab: the parts of SpinupTUI that keep working after the TUI is closed.
|
|
2
|
-
// A view over the module contract (src/modules): each shipped module's status on
|
|
3
|
-
// this machine, plus the ones still to come, greyed out. This is also where a
|
|
4
|
-
// module gets set up — discovery and turning it on happen in the same place.
|
|
5
|
-
|
|
6
|
-
import { useEffect, useState } from "react"
|
|
7
|
-
import { useKeyboard } from "@opentui/react"
|
|
8
|
-
import { theme } from "../../lib/theme.ts"
|
|
9
|
-
import { truncate } from "../../lib/format.ts"
|
|
10
|
-
import { Panel } from "../components.tsx"
|
|
11
|
-
import { List, moveSelection } from "../List.tsx"
|
|
12
|
-
import { StatusBar } from "../StatusBar.tsx"
|
|
13
|
-
import { useStore } from "../store.tsx"
|
|
14
|
-
import { toast } from "../toast.ts"
|
|
15
|
-
import { MODULES, UPCOMING_MODULES } from "../../modules/index.ts"
|
|
16
|
-
import type { ModuleId, ModuleStatus } from "../../modules/contract.ts"
|
|
17
|
-
import { beaconFromConfig, beaconSend, testMessage } from "../../lib/beacon.ts"
|
|
18
|
-
import { loadConfig } from "../../config.ts"
|
|
19
|
-
|
|
20
|
-
interface Card {
|
|
21
|
-
id: ModuleId
|
|
22
|
-
name: string
|
|
23
|
-
summary: string
|
|
24
|
-
dependsOn: ModuleId[]
|
|
25
|
-
upcoming: boolean
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const CARDS: Card[] = [
|
|
29
|
-
...MODULES.map((m) => ({ id: m.id, name: m.name, summary: m.summary, dependsOn: m.dependsOn, upcoming: false })),
|
|
30
|
-
...UPCOMING_MODULES.map((m) => ({ ...m, upcoming: true })),
|
|
31
|
-
]
|
|
32
|
-
|
|
33
|
-
const NAMES = new Map(CARDS.map((c) => [c.id, c.name]))
|
|
34
|
-
|
|
35
|
-
function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string; label: string; color: string } {
|
|
36
|
-
if (card.upcoming) return { dot: "·", label: "coming soon", color: theme.textFaint }
|
|
37
|
-
if (!status) return { dot: "○", label: "checking…", color: theme.textDim }
|
|
38
|
-
if (status.state === "ok") return { dot: "●", label: "set up", color: theme.good }
|
|
39
|
-
if (status.state === "degraded") return { dot: "●", label: "needs attention", color: theme.warn }
|
|
40
|
-
return { dot: "○", label: "not set up", color: theme.textDim }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function Modules({ rows }: { rows: number }) {
|
|
44
|
-
const store = useStore()
|
|
45
|
-
const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen } = store
|
|
46
|
-
const [selected, setSelected] = useState(0)
|
|
47
|
-
const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
|
|
48
|
-
const [testing, setTesting] = useState(false)
|
|
49
|
-
|
|
50
|
-
// Recompute on open and whenever the setup overlay closes (it may have
|
|
51
|
-
// changed the config). status() is cheap and never sends anything.
|
|
52
|
-
const [tick, setTick] = useState(0)
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (beaconSetupOpen) return
|
|
55
|
-
let live = true
|
|
56
|
-
void Promise.all(MODULES.map((m) => m.status())).then((list) => {
|
|
57
|
-
if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
|
|
58
|
-
})
|
|
59
|
-
return () => {
|
|
60
|
-
live = false
|
|
61
|
-
}
|
|
62
|
-
}, [beaconSetupOpen, tick])
|
|
63
|
-
|
|
64
|
-
const isActive = route === "modules" && !inputMode && !overlayOpen
|
|
65
|
-
const card = CARDS[selected]!
|
|
66
|
-
const status = statuses.get(card.id)
|
|
67
|
-
|
|
68
|
-
const sendTest = () => {
|
|
69
|
-
if (testing) return
|
|
70
|
-
setTesting(true)
|
|
71
|
-
void beaconSend(beaconFromConfig(loadConfig()), testMessage()).then((r) => {
|
|
72
|
-
setTesting(false)
|
|
73
|
-
setTick((t) => t + 1)
|
|
74
|
-
if (r.ok) toast.success(`Test page sent via ${r.channel}`)
|
|
75
|
-
else toast.error(r.message)
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
useKeyboard((key) => {
|
|
80
|
-
if (!isActive) return
|
|
81
|
-
switch (key.name) {
|
|
82
|
-
case "up":
|
|
83
|
-
case "k":
|
|
84
|
-
return setSelected((i) => moveSelection(i, -1, CARDS.length))
|
|
85
|
-
case "down":
|
|
86
|
-
case "j":
|
|
87
|
-
return setSelected((i) => moveSelection(i, 1, CARDS.length))
|
|
88
|
-
case "return":
|
|
89
|
-
case "s":
|
|
90
|
-
if (card.id === "beacon") return setBeaconSetupOpen(true)
|
|
91
|
-
return
|
|
92
|
-
case "t":
|
|
93
|
-
if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
const beaconReady = card.id === "beacon" && status !== undefined && status.state !== "not_configured"
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
102
|
-
<box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
|
|
103
|
-
<Panel title=" Modules " active width={40}>
|
|
104
|
-
<List
|
|
105
|
-
items={CARDS}
|
|
106
|
-
selectedIndex={selected}
|
|
107
|
-
viewportRows={Math.max(3, rows - 4)}
|
|
108
|
-
focused
|
|
109
|
-
keyFor={(c) => c.id}
|
|
110
|
-
renderRow={(c, sel) => {
|
|
111
|
-
const s = stateLabel(c, statuses.get(c.id))
|
|
112
|
-
return (
|
|
113
|
-
<>
|
|
114
|
-
<text content={s.dot + " "} fg={s.color} style={{ flexShrink: 0 }} />
|
|
115
|
-
<text
|
|
116
|
-
content={c.name}
|
|
117
|
-
fg={c.upcoming ? theme.textFaint : sel ? theme.text : theme.textDim}
|
|
118
|
-
wrapMode="none"
|
|
119
|
-
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
120
|
-
/>
|
|
121
|
-
<text content={" " + s.label} fg={s.color} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
122
|
-
</>
|
|
123
|
-
)
|
|
124
|
-
}}
|
|
125
|
-
/>
|
|
126
|
-
</Panel>
|
|
127
|
-
|
|
128
|
-
<Panel title={` ${card.name} `} flexGrow={1}>
|
|
129
|
-
<box style={{ flexDirection: "column" }}>
|
|
130
|
-
<text content={card.summary} fg={card.upcoming ? theme.textDim : theme.text} />
|
|
131
|
-
<box style={{ height: 1 }} />
|
|
132
|
-
{card.dependsOn.length > 0 && (
|
|
133
|
-
<text content={`Builds on: ${card.dependsOn.map((d) => NAMES.get(d) ?? d).join(", ")}`} fg={theme.textFaint} />
|
|
134
|
-
)}
|
|
135
|
-
{card.upcoming ? <UpcomingDetail /> : <ModuleDetail card={card} status={status} />}
|
|
136
|
-
</box>
|
|
137
|
-
</Panel>
|
|
138
|
-
</box>
|
|
139
|
-
<StatusBar
|
|
140
|
-
hints={[
|
|
141
|
-
{ key: "↑↓/jk", label: "select" },
|
|
142
|
-
...(card.id === "beacon" ? [{ key: "⏎", label: beaconReady ? "reconfigure" : "set up" }] : []),
|
|
143
|
-
...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
|
|
144
|
-
]}
|
|
145
|
-
/>
|
|
146
|
-
</box>
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function UpcomingDetail() {
|
|
151
|
-
return (
|
|
152
|
-
<box style={{ flexDirection: "column" }}>
|
|
153
|
-
<box style={{ height: 1 }} />
|
|
154
|
-
<text content="Coming soon" fg={theme.purple} attributes={1} />
|
|
155
|
-
<text content="Not in this version yet. It will appear here, ready to set up, when it ships." fg={theme.textFaint} />
|
|
156
|
-
</box>
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | undefined }) {
|
|
161
|
-
if (!status) return <text content="Checking…" fg={theme.textDim} />
|
|
162
|
-
const s = stateLabel(card, status)
|
|
163
|
-
return (
|
|
164
|
-
<box style={{ flexDirection: "column" }}>
|
|
165
|
-
<box style={{ height: 1 }} />
|
|
166
|
-
<box style={{ flexDirection: "row" }}>
|
|
167
|
-
<text content={`${s.dot} `} fg={s.color} style={{ flexShrink: 0 }} />
|
|
168
|
-
<text content={status.summary} fg={s.color} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
169
|
-
</box>
|
|
170
|
-
{status.checks.length > 0 && (
|
|
171
|
-
<>
|
|
172
|
-
<box style={{ height: 1 }} />
|
|
173
|
-
{status.checks.map((c) => (
|
|
174
|
-
<box key={c.name} style={{ flexDirection: "row" }}>
|
|
175
|
-
<text content={c.ok ? "✓ " : "✗ "} fg={c.ok ? theme.good : theme.bad} style={{ flexShrink: 0 }} />
|
|
176
|
-
<text content={`${c.name}: `} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
177
|
-
<text content={truncate(c.detail, 80)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
178
|
-
</box>
|
|
179
|
-
))}
|
|
180
|
-
</>
|
|
181
|
-
)}
|
|
182
|
-
<box style={{ height: 1 }} />
|
|
183
|
-
{card.id === "beacon" && status.state === "not_configured" && (
|
|
184
|
-
<>
|
|
185
|
-
<text content="Beacon pages you on Telegram when something needs you. Scripts, cron jobs and" fg={theme.textDim} />
|
|
186
|
-
<text content={'the other modules all send through it: spinuptui beacon send "…".'} fg={theme.textDim} />
|
|
187
|
-
<box style={{ height: 1 }} />
|
|
188
|
-
<text content="Press ⏎ to connect a Telegram bot. It takes about two minutes." fg={theme.accent} />
|
|
189
|
-
</>
|
|
190
|
-
)}
|
|
191
|
-
{card.id === "beacon" && status.state !== "not_configured" && (
|
|
192
|
-
<text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
|
|
193
|
-
)}
|
|
194
|
-
</box>
|
|
195
|
-
)
|
|
196
|
-
}
|