feat: add operator refresh and force-sync controls
This commit is contained in:
+60
-4
@@ -3,7 +3,7 @@ import type { ChangeEvent } from 'react'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
|
import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
|
||||||
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
|
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 { 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 type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
|
||||||
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
|
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 [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
|
||||||
const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting')
|
const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting')
|
||||||
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
||||||
|
const [syncAction, setSyncAction] = useState<'idle' | 'refreshing' | 'pushing'>('idle')
|
||||||
const hasHydratedRemote = useRef(false)
|
const hasHydratedRemote = useRef(false)
|
||||||
const initialLocalStateRef = useRef(appState)
|
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) => {
|
const openFeatureHandoff = (featureId: string) => {
|
||||||
setPromptFeatureId(featureId)
|
setPromptFeatureId(featureId)
|
||||||
setPromptTarget('OpenClaw')
|
setPromptTarget('OpenClaw')
|
||||||
@@ -602,16 +652,22 @@ function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="status-strip card">
|
<section className="status-strip card">
|
||||||
<div>
|
<div className="status-strip-row">
|
||||||
<span className={`pill status-${backendMode === 'appwrite' ? 'healthy' : backendMode === 'connecting' ? 'connecting' : 'degraded'}`}>
|
<span className={`pill status-${backendMode === 'appwrite' ? 'healthy' : backendMode === 'connecting' ? 'connecting' : 'degraded'}`}>
|
||||||
{backendLabel}
|
{backendLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className={`pill status-${syncStatus === 'synced' ? 'healthy' : syncStatus === 'pending' || syncStatus === 'syncing' || syncStatus === 'connecting' ? 'connecting' : 'degraded'}`}>
|
<span className={`pill status-${syncStatus === 'synced' ? 'healthy' : syncStatus === 'pending' || syncStatus === 'syncing' || syncStatus === 'connecting' ? 'connecting' : 'degraded'}`}>
|
||||||
{syncLabel}
|
{syncLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section className="project-card card">
|
<section className="project-card card">
|
||||||
|
|||||||
+18
-1
@@ -326,6 +326,13 @@ button.small {
|
|||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled,
|
||||||
|
.import-label:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.import-label {
|
.import-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -377,7 +384,8 @@ pre {
|
|||||||
|
|
||||||
.column-body,
|
.column-body,
|
||||||
.list-stack,
|
.list-stack,
|
||||||
.markdown-list {
|
.markdown-list,
|
||||||
|
.status-strip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -497,6 +505,13 @@ pre {
|
|||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
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 {
|
.status-bar {
|
||||||
padding: 0.95rem 1.1rem;
|
padding: 0.95rem 1.1rem;
|
||||||
background: rgba(15, 23, 42, 0.78);
|
background: rgba(15, 23, 42, 0.78);
|
||||||
@@ -562,8 +577,10 @@ pre {
|
|||||||
.quick-actions,
|
.quick-actions,
|
||||||
.tab-bar,
|
.tab-bar,
|
||||||
.button-row,
|
.button-row,
|
||||||
|
.button-inline-row,
|
||||||
.filter-row,
|
.filter-row,
|
||||||
.status-strip,
|
.status-strip,
|
||||||
|
.status-strip-row,
|
||||||
.status-bar,
|
.status-bar,
|
||||||
.feature-signal-row {
|
.feature-signal-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+9
-1
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user