spinuptui 0.19.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/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Fleet dashboard: at-a-glance health of every server and site in the account.
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react"
|
|
4
|
+
import { theme, diskColor } from "../../lib/theme.ts"
|
|
5
|
+
import { formatBytes, diskUsedPct, bar, truncate, timeAgo } from "../../lib/format.ts"
|
|
6
|
+
import { Panel } from "../components.tsx"
|
|
7
|
+
import { useStore } from "../store.tsx"
|
|
8
|
+
import type { Server } from "../../api/types.ts"
|
|
9
|
+
|
|
10
|
+
// A single headline metric card. Height 5 = 2 border rows + 3 content lines.
|
|
11
|
+
function Stat({ label, value, color = theme.text, sub }: { label: string; value: string; color?: string; sub?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<box
|
|
14
|
+
border
|
|
15
|
+
borderColor={theme.border}
|
|
16
|
+
style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, height: 5 }}
|
|
17
|
+
>
|
|
18
|
+
<text content={label} fg={theme.textDim} />
|
|
19
|
+
<text content={value} fg={color} attributes={1} />
|
|
20
|
+
<text content={sub ?? ""} fg={theme.textFaint} />
|
|
21
|
+
</box>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function serverDiskFraction(s: Server): number {
|
|
26
|
+
return diskUsedPct(s.disk_space?.used, s.disk_space?.total) / 100
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Dashboard({ rows }: { rows: number }) {
|
|
30
|
+
const { servers, sites, events, isServerOsEol } = useStore()
|
|
31
|
+
|
|
32
|
+
const agg = useMemo(() => {
|
|
33
|
+
const connected = servers.filter((s) => s.connection_status === "connected").length
|
|
34
|
+
const reboot = servers.filter((s) => s.reboot_required).length
|
|
35
|
+
const upgrade = servers.filter((s) => s.upgrade_required).length
|
|
36
|
+
let used = 0
|
|
37
|
+
let total = 0
|
|
38
|
+
for (const s of servers) {
|
|
39
|
+
if (s.disk_space) {
|
|
40
|
+
used += s.disk_space.used || 0
|
|
41
|
+
total += s.disk_space.total || 0
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const wp = sites.filter((s) => s.is_wordpress).length
|
|
45
|
+
const pluginUpd = sites.reduce((n, s) => n + (s.wp_plugin_updates || 0), 0)
|
|
46
|
+
const themeUpd = sites.reduce((n, s) => n + (s.wp_theme_updates || 0), 0)
|
|
47
|
+
const coreUpd = sites.filter((s) => s.wp_core_update).length
|
|
48
|
+
const https = sites.filter((s) => s.https?.enabled).length
|
|
49
|
+
return { connected, reboot, upgrade, used, total, wp, pluginUpd, themeUpd, coreUpd, https }
|
|
50
|
+
}, [servers, sites])
|
|
51
|
+
|
|
52
|
+
// Servers sorted by disk pressure (busiest first) for the watchlist.
|
|
53
|
+
const byDisk = useMemo(
|
|
54
|
+
() => [...servers].sort((a, b) => serverDiskFraction(b) - serverDiskFraction(a)),
|
|
55
|
+
[servers],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Things that want attention: reboots, upgrades, pending WP updates.
|
|
59
|
+
const attention = useMemo(() => {
|
|
60
|
+
const items: { text: string; color: string }[] = []
|
|
61
|
+
for (const s of servers) {
|
|
62
|
+
if (s.reboot_required) items.push({ text: `${s.name} — reboot required`, color: theme.warn })
|
|
63
|
+
if (s.upgrade_required) items.push({ text: `${s.name} — SpinupWP upgrade required`, color: theme.warn })
|
|
64
|
+
if (isServerOsEol(s)) items.push({ text: `${s.name} — Ubuntu ${s.ubuntu_version} is EOL, clone to a newer server`, color: theme.bad })
|
|
65
|
+
}
|
|
66
|
+
return items
|
|
67
|
+
}, [servers, isServerOsEol])
|
|
68
|
+
|
|
69
|
+
const diskPct = agg.total > 0 ? (agg.used / agg.total) * 100 : 0
|
|
70
|
+
const listRows = Math.max(3, rows - 9) // minus the stat-card band (5), status bar, and panel chrome
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
|
|
74
|
+
{/* Headline metrics */}
|
|
75
|
+
<box style={{ flexDirection: "row", gap: 1 }}>
|
|
76
|
+
<Stat label="Servers" value={String(servers.length)} color={theme.brand} sub={`${agg.connected} connected`} />
|
|
77
|
+
<Stat label="Sites" value={String(sites.length)} color={theme.accent} sub={`${agg.wp} WordPress`} />
|
|
78
|
+
<Stat
|
|
79
|
+
label="Fleet Disk"
|
|
80
|
+
value={`${diskPct.toFixed(0)}%`}
|
|
81
|
+
color={diskColor(diskPct)}
|
|
82
|
+
sub={`${formatBytes(agg.used)} / ${formatBytes(agg.total)}`}
|
|
83
|
+
/>
|
|
84
|
+
<Stat
|
|
85
|
+
label="WP Updates"
|
|
86
|
+
value={String(agg.pluginUpd + agg.themeUpd + agg.coreUpd)}
|
|
87
|
+
color={agg.pluginUpd + agg.themeUpd + agg.coreUpd > 0 ? theme.warn : theme.good}
|
|
88
|
+
sub={`${agg.pluginUpd} plugin · ${agg.coreUpd} core`}
|
|
89
|
+
/>
|
|
90
|
+
<Stat
|
|
91
|
+
label="Maintenance"
|
|
92
|
+
value={String(agg.reboot + agg.upgrade)}
|
|
93
|
+
color={agg.reboot + agg.upgrade > 0 ? theme.warn : theme.good}
|
|
94
|
+
sub={`${agg.reboot} reboot · ${agg.upgrade} upgrade`}
|
|
95
|
+
/>
|
|
96
|
+
</box>
|
|
97
|
+
|
|
98
|
+
{/* Two columns: disk watchlist + attention/activity */}
|
|
99
|
+
<box style={{ flexGrow: 1, flexDirection: "row", gap: 1, marginTop: 1 }}>
|
|
100
|
+
<Panel title=" Disk usage by server " flexGrow={1}>
|
|
101
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
102
|
+
{byDisk.slice(0, listRows).map((s) => {
|
|
103
|
+
const frac = serverDiskFraction(s)
|
|
104
|
+
const pct = frac * 100
|
|
105
|
+
return (
|
|
106
|
+
<box key={s.id} style={{ flexDirection: "row", height: 1 }}>
|
|
107
|
+
<text content={truncate(s.name, 30)} fg={theme.text} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
108
|
+
<text content={bar(frac, 10)} fg={diskColor(pct)} style={{ flexShrink: 0 }} />
|
|
109
|
+
<text content={` ${pct.toFixed(0)}%`.padStart(5)} fg={diskColor(pct)} style={{ flexShrink: 0 }} />
|
|
110
|
+
<text content={s.disk_space ? ` ${formatBytes(s.disk_space.used)}` : " —"} fg={theme.textDim} style={{ flexShrink: 0 }} />
|
|
111
|
+
</box>
|
|
112
|
+
)
|
|
113
|
+
})}
|
|
114
|
+
</box>
|
|
115
|
+
</Panel>
|
|
116
|
+
|
|
117
|
+
<box style={{ flexDirection: "column", flexGrow: 1, gap: 1 }}>
|
|
118
|
+
<Panel title={` Needs attention (${attention.length}) `} flexGrow={1}>
|
|
119
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
120
|
+
{attention.length === 0 ? (
|
|
121
|
+
<text content="✓ All servers healthy" fg={theme.good} />
|
|
122
|
+
) : (
|
|
123
|
+
attention.slice(0, Math.max(1, Math.floor(listRows / 2))).map((a, i) => (
|
|
124
|
+
<box key={i} style={{ flexDirection: "row", height: 1 }}>
|
|
125
|
+
<text content={`• ${truncate(a.text, 60)}`} fg={a.color} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
126
|
+
</box>
|
|
127
|
+
))
|
|
128
|
+
)}
|
|
129
|
+
</box>
|
|
130
|
+
</Panel>
|
|
131
|
+
<Panel title=" Recent activity " flexGrow={1}>
|
|
132
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
133
|
+
{events.length === 0 ? (
|
|
134
|
+
<text content="No recent events" fg={theme.textFaint} />
|
|
135
|
+
) : (
|
|
136
|
+
events.slice(0, Math.max(1, Math.floor(listRows / 2))).map((e) => (
|
|
137
|
+
<box key={e.id} style={{ flexDirection: "row", height: 1 }}>
|
|
138
|
+
<text content={truncate(e.name, 44)} fg={theme.text} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
139
|
+
<text content={" " + timeAgo(e.created_at)} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
140
|
+
</box>
|
|
141
|
+
))
|
|
142
|
+
)}
|
|
143
|
+
</box>
|
|
144
|
+
</Panel>
|
|
145
|
+
</box>
|
|
146
|
+
</box>
|
|
147
|
+
</box>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Production DB-backup overlay.
|
|
2
|
+
//
|
|
3
|
+
// Opened with `d` on a linked site (Search). Confirms what will run, then exports
|
|
4
|
+
// the remote database, downloads it (gzipped) into the local project's sql/ dir,
|
|
5
|
+
// and cleans up the remote copy. The export + download run in the store
|
|
6
|
+
// (`startDbBackup`), so closing this modal (Esc/q) doesn't abandon them — the
|
|
7
|
+
// download keeps going and the result is still in the store when reopened.
|
|
8
|
+
//
|
|
9
|
+
// The whole operation is read-only on production (an export, never a write).
|
|
10
|
+
|
|
11
|
+
import { useState } from "react"
|
|
12
|
+
import { useKeyboard } from "@opentui/react"
|
|
13
|
+
import { theme } from "../../lib/theme.ts"
|
|
14
|
+
import { truncate, formatBytes, middleTruncate } from "../../lib/format.ts"
|
|
15
|
+
import { Panel, Centered, DestPath, Steps, type StepRow } from "../components.tsx"
|
|
16
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
17
|
+
import { useStore } from "../store.tsx"
|
|
18
|
+
import type { DbBackupStage } from "../../lib/dbBackup.ts"
|
|
19
|
+
|
|
20
|
+
// The backup's stages in run order — rendered as a building checklist.
|
|
21
|
+
const BACKUP_STEPS: { stage: DbBackupStage; label: string }[] = [
|
|
22
|
+
{ stage: "export", label: "Export database on server" },
|
|
23
|
+
{ stage: "download", label: "Download the backup" },
|
|
24
|
+
{ stage: "cleanup", label: "Clean up remote copy" },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export function DbBackup() {
|
|
28
|
+
const { dbBackupSite: site, setDbBackupSite, serverById, planDbBackupFor, dbBackups, startDbBackup, clearDbBackup } = useStore()
|
|
29
|
+
|
|
30
|
+
// Resolve the plan up front so the confirm screen can show source + destination
|
|
31
|
+
// (and surface an unrunnable link before the user commits).
|
|
32
|
+
const planResult = site ? planDbBackupFor(site) : null
|
|
33
|
+
const progress = site ? dbBackups.get(site.id) : undefined
|
|
34
|
+
|
|
35
|
+
const [started, setStarted] = useState(false)
|
|
36
|
+
|
|
37
|
+
// Display phase: a planning error blocks before anything runs; otherwise the
|
|
38
|
+
// screen follows the store's progress once we've fired the backup.
|
|
39
|
+
const dp: "blocked" | "confirm" | "running" | "done" | "error" =
|
|
40
|
+
planResult && !planResult.ok && !progress
|
|
41
|
+
? "blocked"
|
|
42
|
+
: !started && !progress
|
|
43
|
+
? "confirm"
|
|
44
|
+
: progress?.stage === "done"
|
|
45
|
+
? "done"
|
|
46
|
+
: progress?.stage === "error"
|
|
47
|
+
? "error"
|
|
48
|
+
: "running"
|
|
49
|
+
|
|
50
|
+
const close = () => {
|
|
51
|
+
// Drop a settled (done/failed) entry so reopening starts fresh; leave an
|
|
52
|
+
// in-flight download running with its progress intact.
|
|
53
|
+
if (site && (progress?.stage === "done" || progress?.stage === "error")) clearDbBackup(site.id)
|
|
54
|
+
setDbBackupSite(null)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
useKeyboard((key) => {
|
|
58
|
+
const name = key.name ?? ""
|
|
59
|
+
if (name === "escape" || name === "q") return close()
|
|
60
|
+
|
|
61
|
+
if (dp === "confirm") {
|
|
62
|
+
if (name === "return" || name === "y") {
|
|
63
|
+
if (site) {
|
|
64
|
+
startDbBackup(site)
|
|
65
|
+
setStarted(true)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (dp === "error") {
|
|
72
|
+
if (name === "r") {
|
|
73
|
+
if (site) clearDbBackup(site.id)
|
|
74
|
+
setStarted(false)
|
|
75
|
+
}
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (!site) return null
|
|
81
|
+
const server = serverById(site.server_id)
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<box
|
|
85
|
+
style={{
|
|
86
|
+
position: "absolute",
|
|
87
|
+
top: 0,
|
|
88
|
+
left: 0,
|
|
89
|
+
width: "100%",
|
|
90
|
+
height: "100%",
|
|
91
|
+
flexDirection: "column",
|
|
92
|
+
backgroundColor: theme.bg,
|
|
93
|
+
zIndex: 210,
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<box
|
|
97
|
+
style={{
|
|
98
|
+
flexDirection: "row",
|
|
99
|
+
height: 1,
|
|
100
|
+
backgroundColor: theme.bgAlt,
|
|
101
|
+
paddingLeft: 1,
|
|
102
|
+
paddingRight: 1,
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<text content="⬇ Download DB backup " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
107
|
+
<text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
108
|
+
<box style={{ flexGrow: 1 }} />
|
|
109
|
+
<text content="read-only export" fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
110
|
+
</box>
|
|
111
|
+
|
|
112
|
+
<Centered>{renderBody()}</Centered>
|
|
113
|
+
|
|
114
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
115
|
+
</box>
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
function renderBody() {
|
|
119
|
+
if (dp === "blocked") {
|
|
120
|
+
return (
|
|
121
|
+
<Panel title=" Can't back up yet " active>
|
|
122
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
123
|
+
<text content={planResult && !planResult.ok ? planResult.error : "Unavailable."} fg={theme.warn} wrapMode="none" />
|
|
124
|
+
<box style={{ height: 1 }} />
|
|
125
|
+
<text content="The backup downloads into the linked working copy's sql/ folder." fg={theme.textDim} wrapMode="none" />
|
|
126
|
+
<box style={{ height: 1 }} />
|
|
127
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
128
|
+
</box>
|
|
129
|
+
</Panel>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (dp === "confirm") {
|
|
134
|
+
const plan = planResult && planResult.ok ? planResult.plan : null
|
|
135
|
+
return (
|
|
136
|
+
<Panel title=" Download production database " active>
|
|
137
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
138
|
+
<box style={{ flexDirection: "row" }}>
|
|
139
|
+
<text content="Export " fg={theme.text} />
|
|
140
|
+
<text content={truncate(site!.domain, 36)} fg={theme.accent} wrapMode="none" />
|
|
141
|
+
<text content=" on " fg={theme.textDim} />
|
|
142
|
+
<text content={truncate(server?.name ?? "—", 24)} fg={theme.textDim} wrapMode="none" />
|
|
143
|
+
</box>
|
|
144
|
+
<box style={{ height: 1 }} />
|
|
145
|
+
<text content="from" fg={theme.textFaint} />
|
|
146
|
+
<text content={` ${middleTruncate(plan?.docroot ?? "—", 62)}`} fg={theme.textDim} wrapMode="none" />
|
|
147
|
+
<text content="to" fg={theme.textFaint} />
|
|
148
|
+
{plan ? <DestPath path={plan.destPath} fileColor={theme.good} width={62} /> : <text content=" —" fg={theme.textDim} />}
|
|
149
|
+
<box style={{ height: 1 }} />
|
|
150
|
+
<text content="Exports with wp-cli, gzips it, downloads it, then removes the" fg={theme.textDim} wrapMode="none" />
|
|
151
|
+
<text content="remote copy. Nothing on production is modified." fg={theme.textDim} wrapMode="none" />
|
|
152
|
+
<box style={{ height: 1 }} />
|
|
153
|
+
<text content="Press y / ⏎ to download · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
154
|
+
</box>
|
|
155
|
+
</Panel>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// running | done | error share one bordered checklist so the whole backup
|
|
160
|
+
// reads as a building stack; the footer carries the live/result/error status.
|
|
161
|
+
const order = BACKUP_STEPS.map((s) => s.stage)
|
|
162
|
+
const curIdx = order.indexOf((progress?.stage ?? "export") as DbBackupStage)
|
|
163
|
+
const failedIdx = progress?.failedStage ? order.indexOf(progress.failedStage) : order.length - 1
|
|
164
|
+
const rows: StepRow[] = BACKUP_STEPS.map(({ label }, i) => {
|
|
165
|
+
const state: StepRow["state"] =
|
|
166
|
+
dp === "done"
|
|
167
|
+
? "done"
|
|
168
|
+
: dp === "error"
|
|
169
|
+
? i < failedIdx
|
|
170
|
+
? "done"
|
|
171
|
+
: i === failedIdx
|
|
172
|
+
? "failed"
|
|
173
|
+
: "pending"
|
|
174
|
+
: i < curIdx
|
|
175
|
+
? "done"
|
|
176
|
+
: i === curIdx
|
|
177
|
+
? "active"
|
|
178
|
+
: "pending"
|
|
179
|
+
return { label, state }
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<Panel title=" Download production database " active>
|
|
184
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
185
|
+
<Steps rows={rows} />
|
|
186
|
+
<box style={{ height: 1 }} />
|
|
187
|
+
{dp === "done" ? (
|
|
188
|
+
<>
|
|
189
|
+
<box style={{ flexDirection: "row" }}>
|
|
190
|
+
<text content="✓ Done — saved " fg={theme.good} />
|
|
191
|
+
<text content={progress?.bytes != null ? formatBytes(progress.bytes) : ""} fg={theme.text} />
|
|
192
|
+
</box>
|
|
193
|
+
<box style={{ height: 1 }} />
|
|
194
|
+
{progress?.destPath ? <DestPath path={progress.destPath} fileColor={theme.accent} width={62} /> : null}
|
|
195
|
+
<box style={{ height: 1 }} />
|
|
196
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
197
|
+
</>
|
|
198
|
+
) : dp === "error" ? (
|
|
199
|
+
<>
|
|
200
|
+
<text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
201
|
+
<box style={{ height: 1 }} />
|
|
202
|
+
<text content="Press r to try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
203
|
+
</>
|
|
204
|
+
) : (
|
|
205
|
+
<text content="You can press Esc — the download keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
206
|
+
)}
|
|
207
|
+
</box>
|
|
208
|
+
</Panel>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function hints() {
|
|
213
|
+
switch (dp) {
|
|
214
|
+
case "confirm":
|
|
215
|
+
return [
|
|
216
|
+
{ key: "y/⏎", label: "download" },
|
|
217
|
+
{ key: "esc", label: "cancel" },
|
|
218
|
+
]
|
|
219
|
+
case "error":
|
|
220
|
+
return [
|
|
221
|
+
{ key: "r", label: "retry" },
|
|
222
|
+
{ key: "esc", label: "close" },
|
|
223
|
+
]
|
|
224
|
+
default:
|
|
225
|
+
return [{ key: "esc", label: "close" }]
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Production → local DB sync overlay.
|
|
2
|
+
//
|
|
3
|
+
// Opened with `p` on a linked WordPress site (Search). Pulls the production
|
|
4
|
+
// database into the local working copy: backs up local first, exports/downloads
|
|
5
|
+
// prod (read-only), imports it, rewrites URLs, and runs an optional post-import
|
|
6
|
+
// hook. DESTRUCTIVE on local — so the confirm screen says so plainly. The work
|
|
7
|
+
// runs in the store (`startDbSync`), so closing the modal (Esc/q) doesn't abandon
|
|
8
|
+
// it; the row keeps a spinner and reopening shows live progress.
|
|
9
|
+
|
|
10
|
+
import { useState } from "react"
|
|
11
|
+
import { useKeyboard } from "@opentui/react"
|
|
12
|
+
import { theme } from "../../lib/theme.ts"
|
|
13
|
+
import { truncate, middleTruncate } from "../../lib/format.ts"
|
|
14
|
+
import { Panel, Centered, DestPath, Steps, type StepRow } from "../components.tsx"
|
|
15
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
16
|
+
import { useStore } from "../store.tsx"
|
|
17
|
+
import { writeSampleHook, type DbSyncStage } from "../../lib/dbSync.ts"
|
|
18
|
+
|
|
19
|
+
// The pull's stages in run order — rendered as a building checklist. The hook
|
|
20
|
+
// row is included only when the plan found a bin/sync.d/post-import.sh.
|
|
21
|
+
const SYNC_STEPS: { stage: DbSyncStage; label: string }[] = [
|
|
22
|
+
{ stage: "local-backup", label: "Back up local database" },
|
|
23
|
+
{ stage: "export", label: "Export production database" },
|
|
24
|
+
{ stage: "download", label: "Download the dump" },
|
|
25
|
+
{ stage: "import", label: "Import into local" },
|
|
26
|
+
{ stage: "replace", label: "Rewrite production URLs → local" },
|
|
27
|
+
{ stage: "hook", label: "Run post-import hook" },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function DbSync() {
|
|
31
|
+
const { dbSyncSite: site, setDbSyncSite, serverById, planDbSyncFor, dbSyncs, startDbSync, clearDbSync, planMediaFallbackFor, setMediaFallbackSite } = useStore()
|
|
32
|
+
|
|
33
|
+
const planResult = site ? planDbSyncFor(site) : null
|
|
34
|
+
const progress = site ? dbSyncs.get(site.id) : undefined
|
|
35
|
+
const [started, setStarted] = useState(false)
|
|
36
|
+
// Result of scaffolding a sample post-import hook (s on the confirm screen).
|
|
37
|
+
const [scaffold, setScaffold] = useState<{ ok: true; path: string } | { ok: false; error: string } | null>(null)
|
|
38
|
+
|
|
39
|
+
const dp: "blocked" | "confirm" | "running" | "done" | "error" =
|
|
40
|
+
planResult && !planResult.ok && !progress
|
|
41
|
+
? "blocked"
|
|
42
|
+
: !started && !progress
|
|
43
|
+
? "confirm"
|
|
44
|
+
: progress?.stage === "done"
|
|
45
|
+
? "done"
|
|
46
|
+
: progress?.stage === "error"
|
|
47
|
+
? "error"
|
|
48
|
+
: "running"
|
|
49
|
+
|
|
50
|
+
const close = () => {
|
|
51
|
+
if (site && (progress?.stage === "done" || progress?.stage === "error")) clearDbSync(site.id)
|
|
52
|
+
setDbSyncSite(null)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
useKeyboard((key) => {
|
|
56
|
+
const name = key.name ?? ""
|
|
57
|
+
if (name === "escape" || name === "q") return close()
|
|
58
|
+
if (dp === "confirm") {
|
|
59
|
+
if (name === "return" || name === "y") {
|
|
60
|
+
if (site) {
|
|
61
|
+
startDbSync(site)
|
|
62
|
+
setStarted(true)
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
// s: scaffold a sample hook — only when none exists yet.
|
|
67
|
+
if (name === "s" && plan && !plan.hookPath) {
|
|
68
|
+
try {
|
|
69
|
+
setScaffold({ ok: true, path: writeSampleHook(plan.localRoot) })
|
|
70
|
+
} catch (e) {
|
|
71
|
+
setScaffold({ ok: false, error: e instanceof Error ? e.message : "Couldn't write the sample hook." })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (dp === "error") {
|
|
77
|
+
if (name === "r") {
|
|
78
|
+
if (site) clearDbSync(site.id)
|
|
79
|
+
setStarted(false)
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (dp === "done") {
|
|
84
|
+
// m: hand off to the media-fallback overlay for the just-synced site.
|
|
85
|
+
if (name === "m" && site) {
|
|
86
|
+
close()
|
|
87
|
+
setMediaFallbackSite(site)
|
|
88
|
+
}
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (!site) return null
|
|
94
|
+
const server = serverById(site.server_id)
|
|
95
|
+
const plan = planResult && planResult.ok ? planResult.plan : null
|
|
96
|
+
// Whether this site already has the production media-fallback plugin (shapes the
|
|
97
|
+
// Done-screen nudge). Only meaningful on the done screen.
|
|
98
|
+
const mfPlan = dp === "done" ? planMediaFallbackFor(site) : null
|
|
99
|
+
const mediaFallbackEnabled = !!(mfPlan && mfPlan.ok && mfPlan.plan.enabled)
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<box
|
|
103
|
+
style={{
|
|
104
|
+
position: "absolute",
|
|
105
|
+
top: 0,
|
|
106
|
+
left: 0,
|
|
107
|
+
width: "100%",
|
|
108
|
+
height: "100%",
|
|
109
|
+
flexDirection: "column",
|
|
110
|
+
backgroundColor: theme.bg,
|
|
111
|
+
zIndex: 210,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<box
|
|
115
|
+
style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}
|
|
116
|
+
>
|
|
117
|
+
<text content="⇣ Pull production → local " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
118
|
+
<text content={truncate(site.domain, 38)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
119
|
+
<box style={{ flexGrow: 1 }} />
|
|
120
|
+
<text content="overwrites local DB" fg={theme.warn} style={{ flexShrink: 0 }} />
|
|
121
|
+
</box>
|
|
122
|
+
|
|
123
|
+
<Centered>{renderBody()}</Centered>
|
|
124
|
+
|
|
125
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
126
|
+
</box>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// The post-import-hook line(s) on the confirm screen. Three states: a fresh
|
|
130
|
+
// scaffold result (wins, so the user sees their action land), an existing hook
|
|
131
|
+
// (it'll run), or none — in which case we explain the extension point in
|
|
132
|
+
// context and offer `s` to write an inert sample.
|
|
133
|
+
function renderHookSection() {
|
|
134
|
+
if (scaffold?.ok) {
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
<box style={{ height: 1 }} />
|
|
138
|
+
<box style={{ flexDirection: "row" }}>
|
|
139
|
+
<text content="✓ " fg={theme.good} />
|
|
140
|
+
<text content="wrote bin/sync.d/post-import.sh — edit it, then pull." fg={theme.text} wrapMode="none" />
|
|
141
|
+
</box>
|
|
142
|
+
<text content=" (it's inert until you uncomment a command)" fg={theme.textFaint} wrapMode="none" />
|
|
143
|
+
</>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
if (scaffold && !scaffold.ok) {
|
|
147
|
+
return (
|
|
148
|
+
<>
|
|
149
|
+
<box style={{ height: 1 }} />
|
|
150
|
+
<text content={`✕ ${scaffold.error}`} fg={theme.bad} wrapMode="none" />
|
|
151
|
+
</>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (plan?.hookPath) {
|
|
155
|
+
return (
|
|
156
|
+
<>
|
|
157
|
+
<box style={{ height: 1 }} />
|
|
158
|
+
<text content="↪ post-import hook will run (your per-project tweaks)" fg={theme.purple} wrapMode="none" />
|
|
159
|
+
</>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
return (
|
|
163
|
+
<>
|
|
164
|
+
<box style={{ height: 1 }} />
|
|
165
|
+
<text content="No post-import hook set up for this project." fg={theme.textFaint} wrapMode="none" />
|
|
166
|
+
<text content="SpinupTUI can run bin/sync.d/post-import.sh after each pull —" fg={theme.textFaint} wrapMode="none" />
|
|
167
|
+
<text content="e.g. swap Elementor URLs, toggle dev-only plugins." fg={theme.textFaint} wrapMode="none" />
|
|
168
|
+
<text content=" s write a sample hook" fg={theme.textDim} wrapMode="none" />
|
|
169
|
+
</>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderBody() {
|
|
174
|
+
if (dp === "blocked") {
|
|
175
|
+
return (
|
|
176
|
+
<Panel title=" Can't sync yet " active>
|
|
177
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
178
|
+
<text content={planResult && !planResult.ok ? planResult.error : "Unavailable."} fg={theme.warn} wrapMode="none" />
|
|
179
|
+
<box style={{ height: 1 }} />
|
|
180
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
181
|
+
</box>
|
|
182
|
+
</Panel>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (dp === "confirm") {
|
|
187
|
+
return (
|
|
188
|
+
<Panel title=" Pull production database to local " active>
|
|
189
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
190
|
+
<box style={{ flexDirection: "row" }}>
|
|
191
|
+
<text content="⚠ " fg={theme.warn} />
|
|
192
|
+
<text content="This OVERWRITES your local database." fg={theme.text} wrapMode="none" />
|
|
193
|
+
</box>
|
|
194
|
+
<text content=" (your local DB is backed up first, just in case)" fg={theme.textFaint} wrapMode="none" />
|
|
195
|
+
<box style={{ height: 1 }} />
|
|
196
|
+
<box style={{ flexDirection: "row" }}>
|
|
197
|
+
<text content="from " fg={theme.textFaint} />
|
|
198
|
+
<text content={truncate(site!.domain, 30)} fg={theme.accent} wrapMode="none" />
|
|
199
|
+
<text content=" on " fg={theme.textDim} />
|
|
200
|
+
<text content={truncate(server?.name ?? "—", 22)} fg={theme.textDim} wrapMode="none" />
|
|
201
|
+
</box>
|
|
202
|
+
<text content="into" fg={theme.textFaint} />
|
|
203
|
+
{plan ? <DestPath path={plan.localRoot} fileColor={theme.good} width={64} /> : null}
|
|
204
|
+
<box style={{ height: 1 }} />
|
|
205
|
+
<text content="rewrite URLs" fg={theme.textFaint} />
|
|
206
|
+
<text content={` ${plan ? middleTruncate(`${plan.remoteOrigin} → ${plan.localOrigin}`, 64) : "—"}`} fg={theme.textDim} wrapMode="none" />
|
|
207
|
+
{renderHookSection()}
|
|
208
|
+
{plan?.prefixWarning ? (
|
|
209
|
+
<>
|
|
210
|
+
<box style={{ height: 1 }} />
|
|
211
|
+
<text content={`⚠ ${plan.prefixWarning}`} fg={theme.warn} wrapMode="none" />
|
|
212
|
+
</>
|
|
213
|
+
) : null}
|
|
214
|
+
<box style={{ height: 1 }} />
|
|
215
|
+
<text content="Production is only read from — nothing there is modified." fg={theme.textFaint} wrapMode="none" />
|
|
216
|
+
<text content="Press y / ⏎ to pull · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
217
|
+
</box>
|
|
218
|
+
</Panel>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// running | done | error share one bordered checklist so the whole pull
|
|
223
|
+
// reads as a building stack; the footer carries the live/result/error status.
|
|
224
|
+
const steps = SYNC_STEPS.filter((s) => s.stage !== "hook" || !!plan?.hookPath)
|
|
225
|
+
const order = steps.map((s) => s.stage)
|
|
226
|
+
const curIdx = order.indexOf((progress?.stage ?? "local-backup") as DbSyncStage)
|
|
227
|
+
const failedIdx = progress?.failedStage ? order.indexOf(progress.failedStage) : order.length - 1
|
|
228
|
+
const rows: StepRow[] = steps.map(({ label }, i) => {
|
|
229
|
+
const state: StepRow["state"] =
|
|
230
|
+
dp === "done"
|
|
231
|
+
? "done"
|
|
232
|
+
: dp === "error"
|
|
233
|
+
? i < failedIdx
|
|
234
|
+
? "done"
|
|
235
|
+
: i === failedIdx
|
|
236
|
+
? "failed"
|
|
237
|
+
: "pending"
|
|
238
|
+
: i < curIdx
|
|
239
|
+
? "done"
|
|
240
|
+
: i === curIdx
|
|
241
|
+
? "active"
|
|
242
|
+
: "pending"
|
|
243
|
+
return { label, state }
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<Panel title=" Pull production → local " active>
|
|
248
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
249
|
+
<Steps rows={rows} />
|
|
250
|
+
<box style={{ height: 1 }} />
|
|
251
|
+
{dp === "done" ? (
|
|
252
|
+
<>
|
|
253
|
+
<box style={{ flexDirection: "row" }}>
|
|
254
|
+
<text content="✓ " fg={theme.good} />
|
|
255
|
+
<text content="Done — local now mirrors production." fg={theme.text} wrapMode="none" />
|
|
256
|
+
</box>
|
|
257
|
+
<box style={{ height: 1 }} />
|
|
258
|
+
<text content="prod dump saved" fg={theme.textFaint} />
|
|
259
|
+
{progress?.downloadPath ? <DestPath path={progress.downloadPath} fileColor={theme.accent} width={62} /> : null}
|
|
260
|
+
<text content="local DB backup" fg={theme.textFaint} />
|
|
261
|
+
{progress?.localBackupPath ? <DestPath path={progress.localBackupPath} fileColor={theme.textDim} width={62} /> : null}
|
|
262
|
+
<box style={{ height: 1 }} />
|
|
263
|
+
{mediaFallbackEnabled ? (
|
|
264
|
+
<text content="✓ Missing images load from production (media fallback on)." fg={theme.textFaint} wrapMode="none" />
|
|
265
|
+
) : (
|
|
266
|
+
<text content="Images 404ing locally? Press m to serve them from production." fg={theme.purple} wrapMode="none" />
|
|
267
|
+
)}
|
|
268
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
269
|
+
</>
|
|
270
|
+
) : dp === "error" ? (
|
|
271
|
+
<>
|
|
272
|
+
<text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
273
|
+
<box style={{ height: 1 }} />
|
|
274
|
+
<text content="Your local DB backup is in sql/ if you need to restore." fg={theme.textFaint} wrapMode="none" />
|
|
275
|
+
<text content="Press r to try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
276
|
+
</>
|
|
277
|
+
) : (
|
|
278
|
+
<text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
279
|
+
)}
|
|
280
|
+
</box>
|
|
281
|
+
</Panel>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function hints() {
|
|
286
|
+
switch (dp) {
|
|
287
|
+
case "confirm":
|
|
288
|
+
return [
|
|
289
|
+
{ key: "y/⏎", label: "pull" },
|
|
290
|
+
...(plan && !plan.hookPath ? [{ key: "s", label: "sample hook" }] : []),
|
|
291
|
+
{ key: "esc", label: "cancel" },
|
|
292
|
+
]
|
|
293
|
+
case "error":
|
|
294
|
+
return [
|
|
295
|
+
{ key: "r", label: "retry" },
|
|
296
|
+
{ key: "esc", label: "close" },
|
|
297
|
+
]
|
|
298
|
+
case "done":
|
|
299
|
+
return [
|
|
300
|
+
...(mediaFallbackEnabled ? [] : [{ key: "m", label: "media fallback" }]),
|
|
301
|
+
{ key: "esc", label: "close" },
|
|
302
|
+
]
|
|
303
|
+
default:
|
|
304
|
+
return [{ key: "esc", label: "close" }]
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|