Added optional Explorer/Indexer
Build Service / BuildPackage (push) Has been cancelled

This commit is contained in:
2026-05-20 15:43:38 -04:00
parent 2cdc4a5f34
commit e41ff31221
27 changed files with 1431 additions and 70 deletions
+37
View File
@@ -0,0 +1,37 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const disableExplorer = sdk.Action.withoutInput(
// id
'disable-explorer',
// metadata
async ({ effects }) => {
const current = await storeJson.read((s) => s.enableExplorer).once()
return {
name: i18n('Disable Embedded Explorer'),
description: i18n(
'Stop the embedded PostgreSQL + Go indexer. Indexed data on disk (/data/postgres, /data/explorer-indexer) is preserved and will be reused if the explorer is re-enabled later. Service restarts automatically.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: current === true ? 'enabled' : 'hidden',
}
},
// run
async ({ effects }) => {
await storeJson.merge(effects, { enableExplorer: false })
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Embedded explorer disabled. The service is restarting in spaces-only mode (spaced + web terminal). Run "Enable Embedded Explorer" to turn it back on.',
),
result: null,
}
},
)
+37
View File
@@ -0,0 +1,37 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const enableExplorer = sdk.Action.withoutInput(
// id
'enable-explorer',
// metadata
async ({ effects }) => {
const current = await storeJson.read((s) => s.enableExplorer).once()
return {
name: i18n('Enable Embedded Explorer'),
description: i18n(
'Turn on the embedded PostgreSQL + Go indexer. Spaces protocol data (blocks, transactions, spaces, rollouts) will be indexed locally for use by the future explorer web UI. Service restarts automatically.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: current === true ? 'hidden' : 'enabled',
}
},
// run
async ({ effects }) => {
await storeJson.merge(effects, { enableExplorer: true })
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Embedded explorer enabled. The service is restarting; the indexer will begin fetching blocks shortly. First-time start can take several minutes for the Go build.',
),
result: null,
}
},
)
+14 -1
View File
@@ -1,3 +1,4 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, SPACED_CHAIN } from '../utils'
@@ -23,6 +24,18 @@ export const exportWallet = sdk.Action.withoutInput(
// run
async ({ effects }) => {
const spacedAuth = await storeJson.read((s) => s.spacedAuth).once()
if (!spacedAuth) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not export wallet: ${error}', {
error: 'spacedAuth missing from store.json',
}),
result: null,
}
}
const mounts = sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
@@ -32,7 +45,7 @@ export const exportWallet = sdk.Action.withoutInput(
const cmd = [
`mkdir -p ${BACKUP_DIR}`,
`/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie ${dataDir}/${SPACED_CHAIN}/.cookie exportwallet ${BACKUP_PATH}`,
`/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-user ${spacedAuth.username} --rpc-password ${spacedAuth.password} exportwallet ${BACKUP_PATH}`,
].join(' && ')
const result = await sdk.SubContainer.withTemp(
+17 -3
View File
@@ -1,3 +1,4 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, SPACED_CHAIN } from '../utils'
@@ -7,7 +8,6 @@ const { InputSpec, Value } = sdk
const BACKUP_DIR = `${dataDir}/${SPACED_CHAIN}/wallets_backup`
const BACKUP_PATH = `${BACKUP_DIR}/default.json`
const WALLET_DIR = `${dataDir}/${SPACED_CHAIN}/wallets/default`
const COOKIE_PATH = `${dataDir}/${SPACED_CHAIN}/.cookie`
const inputSpec = InputSpec.of({
walletJson: Value.textarea({
@@ -63,9 +63,23 @@ export const importWallet = sdk.Action.withInput(
}
}
const spacedAuth = await storeJson.read((s) => s.spacedAuth).once()
if (!spacedAuth) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not import wallet: ${error}', {
error: 'spacedAuth missing from store.json',
}),
result: null,
}
}
// base64-encode so shell quoting can never break on user content.
const b64 = Buffer.from(input.walletJson, 'utf8').toString('base64')
const cliAuth = `--rpc-user '${spacedAuth.username}' --rpc-password '${spacedAuth.password}'`
const script = `set -e
mkdir -p '${BACKUP_DIR}'
@@ -93,8 +107,8 @@ if [ -d "$WDIR" ]; then
fi
# Import + load via spaced RPC.
/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie '${COOKIE_PATH}' importwallet "$BACKUP"
/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie '${COOKIE_PATH}' loadwallet
/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} ${cliAuth} importwallet "$BACKUP"
/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} ${cliAuth} loadwallet
`
const res = await sdk.SubContainer.withTemp(
+12
View File
@@ -1,10 +1,16 @@
import { sdk } from '../sdk'
import { disableExplorer } from './disableExplorer'
import { enableExplorer } from './enableExplorer'
import { exportWallet } from './exportWallet'
import { importWallet } from './importWallet'
import { resetDbState } from './resetDbState'
import { resetExplorerState } from './resetExplorerState'
import { resetIndexerState } from './resetIndexerState'
import { resetPassword } from './resetPassword'
import { resetSpacedState } from './resetSpacedState'
import { setBitcoinRpc } from './setBitcoinRpc'
import { showCredentials } from './showCredentials'
import { showDbCredentials } from './showDbCredentials'
import { showPassword } from './showPassword'
import { syncStatus } from './syncStatus'
@@ -17,3 +23,9 @@ export const actions = sdk.Actions.of()
.addAction(resetSpacedState)
.addAction(exportWallet)
.addAction(importWallet)
.addAction(enableExplorer)
.addAction(disableExplorer)
.addAction(showDbCredentials)
.addAction(resetDbState)
.addAction(resetIndexerState)
.addAction(resetExplorerState)
+58
View File
@@ -0,0 +1,58 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, pgDataDir } from '../utils'
export const resetDbState = sdk.Action.withoutInput(
// id
'reset-db-state',
// metadata
async ({ effects }) => ({
name: i18n('Reset Database State'),
description: i18n(
'Wipe /data/postgres so PostgreSQL re-initializes from scratch.',
),
warning: i18n(
'This deletes all PostgreSQL data on disk. The next start will re-create an empty database. store.json (passwords + RPC credentials) is preserved.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-reset-db',
(subc) => subc.exec(['rm', '-rf', pgDataDir], { user: 'root' }),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not wipe PostgreSQL state: ${error}', {
error: (res.stderr ?? '').toString() || `exit ${res.exitCode}`,
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'PostgreSQL data has been wiped. Start (or restart) the service to re-initialize the database.',
),
result: null,
}
},
)
+58
View File
@@ -0,0 +1,58 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, EXPLORER_DIR } from '../utils'
export const resetExplorerState = sdk.Action.withoutInput(
// id
'reset-explorer-state',
// metadata
async ({ effects }) => ({
name: i18n('Reset Explorer UI State'),
description: i18n(
'Wipe /data/explorer-ui so the next start re-fetches the explorer source and rebuilds it from scratch.',
),
warning: i18n(
'This deletes the cached explorer source and the built SvelteKit bundle. The next start will need internet access to re-download from GitHub and to fetch npm dependencies. PostgreSQL data is preserved.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'explorer-ui' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-reset-explorer',
(subc) => subc.exec(['rm', '-rf', EXPLORER_DIR], { user: 'root' }),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not wipe explorer state: ${error}', {
error: (res.stderr ?? '').toString() || `exit ${res.exitCode}`,
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Explorer cache has been wiped. Start (or restart) the service to re-fetch and rebuild from GitHub.',
),
result: null,
}
},
)
+58
View File
@@ -0,0 +1,58 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, INDEXER_DIR } from '../utils'
export const resetIndexerState = sdk.Action.withoutInput(
// id
'reset-indexer-state',
// metadata
async ({ effects }) => ({
name: i18n('Reset Indexer State'),
description: i18n(
'Wipe /data/explorer-indexer so the next start re-fetches the indexer source and rebuilds the sync + goose binaries.',
),
warning: i18n(
'This deletes the cached indexer source and compiled binaries. The next start will need internet access to re-download from GitHub and to fetch Go modules. PostgreSQL data (the indexed blocks themselves) is preserved — use Reset Database State if you also want to clear that.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: dataDir,
readonly: false,
}),
'spaces-reset-indexer',
(subc) => subc.exec(['rm', '-rf', INDEXER_DIR], { user: 'root' }),
)
if (res.exitCode !== 0) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not wipe indexer state: ${error}', {
error: (res.stderr ?? '').toString() || `exit ${res.exitCode}`,
}),
result: null,
}
}
return {
version: '1',
title: i18n('Success'),
message: i18n(
'Indexer cache has been wiped. Start (or restart) the service to re-fetch and rebuild from GitHub.',
),
result: null,
}
},
)
+1 -1
View File
@@ -9,7 +9,7 @@ export const resetPassword = sdk.Action.withoutInput(
// metadata
async ({ effects }) => ({
name: i18n('Reset Web UI Password'),
name: i18n('Reset Space-CLI Web UI Password'),
description: i18n(
'Generate a new admin password for the Spaces web terminal',
),
+2 -2
View File
@@ -9,7 +9,7 @@ export const showCredentials = sdk.Action.withoutInput(
// metadata
async ({ effects }) => ({
name: i18n('Show Web UI Credentials'),
name: i18n('Show Space-CLI Web UI Credentials'),
description: i18n(
'Display the username and password for the Spaces web terminal',
),
@@ -25,7 +25,7 @@ export const showCredentials = sdk.Action.withoutInput(
return {
version: '1',
title: i18n('Show Web UI Credentials'),
title: i18n('Show Space-CLI Web UI Credentials'),
message: i18n(
'Use these credentials to log in to the Spaces web terminal.',
),
+81
View File
@@ -0,0 +1,81 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { POSTGRES_PORT } from '../utils'
export const showDbCredentials = sdk.Action.withoutInput(
// id
'show-db-credentials',
// metadata
async ({ effects }) => ({
name: i18n('Show Database Credentials'),
description: i18n(
'Display the PostgreSQL username, password, database, and connection URL.',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// run
async ({ effects }) => {
const dbAuth = await storeJson.read((s) => s.dbAuth).once()
const username = dbAuth?.username ?? ''
const password = dbAuth?.password ?? ''
const database = dbAuth?.database ?? ''
const url = dbAuth
? `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}`
: ''
return {
version: '1',
title: i18n('Show Database Credentials'),
message: i18n(
'Use these credentials to connect to the Spaces PostgreSQL database (loopback only inside the container).',
),
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('Username'),
description: null,
value: username,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Password'),
description: null,
value: password,
masked: true,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Database'),
description: null,
value: database,
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Connection URL'),
description: null,
value: url,
masked: true,
copyable: true,
qr: false,
},
],
},
}
},
)
+2 -2
View File
@@ -9,7 +9,7 @@ export const showPassword = sdk.Action.withoutInput(
// metadata
async ({ effects }) => ({
name: i18n('Show Web UI Password'),
name: i18n('Show Space-CLI Web UI Password'),
description: i18n(
'Display the existing username and password for the Spaces web terminal.',
),
@@ -25,7 +25,7 @@ export const showPassword = sdk.Action.withoutInput(
return {
version: '1',
title: i18n('Show Web UI Password'),
title: i18n('Show Space-CLI Web UI Password'),
message: i18n(
'Use these credentials to log in to the Spaces web terminal.',
),
+15 -2
View File
@@ -1,3 +1,4 @@
import { storeJson } from '../fileModels/storeJson'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { dataDir, SPACED_CHAIN } from '../utils'
@@ -20,6 +21,16 @@ export const syncStatus = sdk.Action.withoutInput(
// run
async ({ effects }) => {
const spacedAuth = await storeJson.read((s) => s.spacedAuth).once()
if (!spacedAuth) {
return {
version: '1',
title: i18n('Failure'),
message: i18n('Could not query spaced. Is the service running?'),
result: null,
}
}
const res = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'spaces' },
@@ -35,8 +46,10 @@ export const syncStatus = sdk.Action.withoutInput(
'/root/.cargo/bin/space-cli',
'--chain',
SPACED_CHAIN,
'--rpc-cookie',
`${dataDir}/${SPACED_CHAIN}/.cookie`,
'--rpc-user',
spacedAuth.username,
'--rpc-password',
spacedAuth.password,
'getserverinfo',
]),
)