feat: add operator refresh and force-sync controls

This commit is contained in:
OpenClaw Bot
2026-05-09 12:49:35 +02:00
parent b33ed20238
commit 2d2febb7aa
3 changed files with 87 additions and 6 deletions
+60 -4
View File
@@ -3,7 +3,7 @@ import type { ChangeEvent } from 'react'
import './index.css'
import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
import { fetchRemoteState, pushRemoteState } from './store/remote'
import { fetchBackendHealth, fetchRemoteState, pushRemoteState } from './store/remote'
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
import type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
@@ -81,6 +81,7 @@ function App() {
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting')
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
const [syncAction, setSyncAction] = useState<'idle' | 'refreshing' | 'pushing'>('idle')
const hasHydratedRemote = useRef(false)
const initialLocalStateRef = useRef(appState)
@@ -319,6 +320,55 @@ function App() {
}))
}
const refreshFromBackend = async () => {
setSyncAction('refreshing')
setSyncStatus('syncing')
try {
const [health, remoteState] = await Promise.all([fetchBackendHealth(), fetchRemoteState()])
if (!health.ok) throw new Error('Backend health check failed.')
if (remoteState && validateAppState(remoteState)) {
setAppState(remoteState)
saveAppState(remoteState)
setBackendMode('appwrite')
setSyncStatus('synced')
setLastSyncedAt(nowIso())
setStatusMessage('Reloaded BuildPulse state from Appwrite.')
} else {
setBackendMode('appwrite')
setSyncStatus('synced')
setStatusMessage('Backend reachable, but there is no valid remote state to reload yet.')
}
} catch {
setBackendMode('local-cache')
setSyncStatus('degraded')
setStatusMessage('Refresh failed. Staying on the local cache until Appwrite behaves again.')
} finally {
setSyncAction('idle')
}
}
const forceSyncNow = async () => {
setSyncAction('pushing')
setSyncStatus('syncing')
try {
await pushRemoteState(appState)
setBackendMode('appwrite')
setSyncStatus('synced')
setLastSyncedAt(nowIso())
setStatusMessage('Forced a clean sync to Appwrite.')
} catch {
setBackendMode('local-cache')
setSyncStatus('degraded')
setStatusMessage('Forced sync failed. Local cache still has the wheel.')
} finally {
setSyncAction('idle')
}
}
const openFeatureHandoff = (featureId: string) => {
setPromptFeatureId(featureId)
setPromptTarget('OpenClaw')
@@ -602,16 +652,22 @@ function App() {
</header>
<section className="status-strip card">
<div>
<div className="status-strip-row">
<span className={`pill status-${backendMode === 'appwrite' ? 'healthy' : backendMode === 'connecting' ? 'connecting' : 'degraded'}`}>
{backendLabel}
</span>
</div>
<div>
<span className={`pill status-${syncStatus === 'synced' ? 'healthy' : syncStatus === 'pending' || syncStatus === 'syncing' || syncStatus === 'connecting' ? 'connecting' : 'degraded'}`}>
{syncLabel}
</span>
</div>
<div className="button-inline-row">
<button type="button" className="ghost small" disabled={syncAction !== 'idle'} onClick={() => void refreshFromBackend()}>
{syncAction === 'refreshing' ? 'Refreshing…' : 'Refresh from backend'}
</button>
<button type="button" className="ghost small" disabled={syncAction !== 'idle'} onClick={() => void forceSyncNow()}>
{syncAction === 'pushing' ? 'Syncing…' : 'Force sync now'}
</button>
</div>
</section>
<section className="project-card card">
+18 -1
View File
@@ -326,6 +326,13 @@ button.small {
padding: 0.45rem 0.75rem;
}
button:disabled,
.import-label:disabled {
opacity: 0.65;
cursor: wait;
transform: none;
}
.import-label {
display: inline-flex;
align-items: center;
@@ -377,7 +384,8 @@ pre {
.column-body,
.list-stack,
.markdown-list {
.markdown-list,
.status-strip-row {
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -497,6 +505,13 @@ pre {
border: 1px solid rgba(148, 163, 184, 0.14);
}
.status-strip-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.status-bar {
padding: 0.95rem 1.1rem;
background: rgba(15, 23, 42, 0.78);
@@ -562,8 +577,10 @@ pre {
.quick-actions,
.tab-bar,
.button-row,
.button-inline-row,
.filter-row,
.status-strip,
.status-strip-row,
.status-bar,
.feature-signal-row {
flex-direction: column;
+9 -1
View File
@@ -46,4 +46,12 @@ export const pushRemoteState = async (state: AppState) => {
})
}
export const fetchBackendHealth = async () => request<{ ok: boolean; backend: string; rows: number }>('/api/health')
export const fetchBackendHealth = async () =>
request<{
ok: boolean
backend: string
endpoint: string
databaseId: string
collectionId: string
documentPresent: boolean
}>('/api/health')