mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 09:02:07 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5eb110123 | ||
|
|
9181d11047 | ||
|
|
2155d0cffa | ||
|
|
a132a33c6b | ||
|
|
c39ae8a375 | ||
|
|
6811bf7c60 | ||
|
|
271d0c47b2 | ||
|
|
e403f2788a | ||
|
|
aae5df728c | ||
|
|
6101bd59dd | ||
|
|
e848d6c25f | ||
|
|
a22ce9ab41 | ||
|
|
6ccd394838 | ||
|
|
37a4db51de | ||
|
|
caf185f917 | ||
|
|
c1a6d38c26 | ||
|
|
ef1d8d2052 | ||
|
|
ff50cafe34 | ||
|
|
ad791b25af | ||
|
|
11d642f4e7 |
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.output
|
||||
.git
|
||||
.github
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: luccasveg
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Create build artifact
|
||||
run: |
|
||||
tar -czvf crabwalk-${{ github.ref_name }}.tar.gz dist
|
||||
|
||||
- name: Upload build to release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: crabwalk-${{ github.ref_name }}.tar.gz
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -34,3 +34,6 @@ src/routeTree.gen.ts
|
||||
|
||||
documents/*
|
||||
.tanstack/tmp/*
|
||||
|
||||
# Persistence data
|
||||
data/
|
||||
|
||||
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
```bash
|
||||
npm run dev # start dev server on port 3000
|
||||
npm run build # production build
|
||||
npm start # run production server (.output/server/index.mjs)
|
||||
npm start # run production server (dist/server/server.js)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -36,7 +36,7 @@ Full-stack React app using TanStack Start (file-based routing, SSR).
|
||||
|
||||
**TanStack DB pattern:** Create collections, use `useLiveQuery()` for reactive reads, `createTransaction()` for writes.
|
||||
|
||||
## Clawdbot Monitor
|
||||
## Moltbot (Clawdbot) Monitor
|
||||
|
||||
Real-time agent activity monitor at `/monitor`.
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# NOTE: TanStack Start server entry produced by `vite build` does not bind a port
|
||||
# on its own in this repo, so we run the Vite dev server in Docker for now.
|
||||
# This makes the published image functional while we figure out a proper prod server.
|
||||
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🦀 Crabwalk
|
||||
|
||||
Real-time companion monitor for [Clawdbot](https://github.com/clawdbot/clawdbot) agents by [@luccasveg](https://x.com/luccasveg).
|
||||
Real-time companion monitor for [Moltbot (Clawdbot)](https://github.com/moltbot/moltbot) agents by [@luccasveg](https://x.com/luccasveg).
|
||||
|
||||
Watch your AI agents work across WhatsApp, Telegram, Discord, and Slack in a live node graph. See thinking states, tool calls, and response chains as they happen.
|
||||
|
||||
@@ -16,28 +16,56 @@ Watch your AI agents work across WhatsApp, Telegram, Discord, and Slack in a liv
|
||||
- **Action tracing** - Expand nodes to inspect tool args and payloads
|
||||
- **Session filtering** - Filter by platform, search by recipient
|
||||
|
||||
## Getting Started
|
||||
## Installation
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e CLAWDBOT_API_TOKEN=your-token \
|
||||
-e CLAWDBOT_URL=ws://host.docker.internal:18789 \
|
||||
ghcr.io/luccast/crabwalk:latest
|
||||
```
|
||||
|
||||
Open `http://localhost:3000/monitor` (or `http://<server-ip>:3000/monitor` for remote access)
|
||||
> Note: When running Crabwalk in Docker, the Clawdbot gateway typically runs on the *host*.
|
||||
> Use `CLAWDBOT_URL=ws://host.docker.internal:18789` so the container can connect.
|
||||
|
||||
Or with docker-compose:
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/luccast/crabwalk/master/docker-compose.yml
|
||||
CLAWDBOT_API_TOKEN=your-token CLAWDBOT_URL=ws://host.docker.internal:18789 docker-compose up -d
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/luccast/crabwalk.git
|
||||
cd crabwalk
|
||||
npm install
|
||||
CLAWDBOT_API_TOKEN=your-token npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000/monitor`
|
||||
|
||||
## Configuration
|
||||
|
||||
Requires clawdbot gateway running on the same machine.
|
||||
|
||||
## Config
|
||||
### Gateway Token
|
||||
|
||||
Token is in `~/.clawdbot/clawdbot.json`
|
||||
Find your token in the clawdbot config file:
|
||||
|
||||
```bash
|
||||
# Option 1: command line
|
||||
CLAWDBOT_API_TOKEN=your-token npm run dev
|
||||
# Look for gateway.auth.token
|
||||
cat ~/.clawdbot/clawdbot.json | rg "gateway\.auth\.token"
|
||||
```
|
||||
|
||||
# Option 2: env file
|
||||
echo "CLAWDBOT_API_TOKEN=your-token" > .env.local
|
||||
npm run dev
|
||||
Or copy it directly:
|
||||
|
||||
```bash
|
||||
export CLAWDBOT_API_TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.clawdbot/clawdbot.json')))['gateway']['auth']['token'])")
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
crabwalk:
|
||||
image: ghcr.io/luccast/crabwalk:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- CLAWDBOT_API_TOKEN=${CLAWDBOT_API_TOKEN}
|
||||
restart: unless-stopped
|
||||
Generated
+4
-17
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.4",
|
||||
"dependencies": {
|
||||
"@tanstack/db": "^0.5.0",
|
||||
"@tanstack/history": "^1.132.0",
|
||||
"@tanstack/react-db": "^0.1.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
@@ -67,7 +70,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1545,7 +1547,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
|
||||
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
},
|
||||
@@ -1580,7 +1581,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.157.13.tgz",
|
||||
"integrity": "sha512-rLr6swdDhLjadAHbukg/XYurNi4q8AmmjP99QiWUnhRPKfrfmQ/O1UCvaowQ9GcaiyOG2COiJ9OlDAQU3k9MFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.154.14",
|
||||
"@tanstack/react-store": "^0.8.0",
|
||||
@@ -1727,7 +1727,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.157.13.tgz",
|
||||
"integrity": "sha512-dl1avpRH2Pi1tYhxDzGEqI6DU9VQUf5zBeW1JJFs3LfJuVkGd2lG93dSUzH9seJNp77hm8JWhoX+k4QYMEB6KA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/history": "1.154.14",
|
||||
"@tanstack/store": "^0.8.0",
|
||||
@@ -2038,7 +2037,6 @@
|
||||
"https://trpc.io/sponsor"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.7.2"
|
||||
}
|
||||
@@ -2206,7 +2204,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2435,7 +2432,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2670,8 +2666,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
@@ -2730,7 +2725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -4574,7 +4568,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4584,7 +4577,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4784,7 +4776,6 @@
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
|
||||
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -4962,7 +4953,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5052,7 +5042,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5269,7 +5258,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -5380,7 +5368,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
+4
-2
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "crabwalk",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000 --host",
|
||||
"build": "vite build",
|
||||
"start": "node .output/server/index.mjs"
|
||||
"start": "node dist/server/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/db": "^0.5.0",
|
||||
@@ -27,7 +28,8 @@
|
||||
"superjson": "^2.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.24.0"
|
||||
"zod": "^3.24.0",
|
||||
"@tanstack/history": "^1.132.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database } from 'lucide-react'
|
||||
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square } from 'lucide-react'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
connected: boolean
|
||||
@@ -8,6 +7,12 @@ interface SettingsPanelProps {
|
||||
debugMode: boolean
|
||||
logCollection: boolean
|
||||
logCount: number
|
||||
persistenceEnabled: boolean
|
||||
persistenceStartedAt: number | null
|
||||
persistenceSessionCount: number
|
||||
persistenceActionCount: number
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onHistoricalModeChange: (enabled: boolean) => void
|
||||
onDebugModeChange: (enabled: boolean) => void
|
||||
onLogCollectionChange: (enabled: boolean) => void
|
||||
@@ -16,6 +21,9 @@ interface SettingsPanelProps {
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
onRefresh: () => void
|
||||
onPersistenceStart: () => void
|
||||
onPersistenceStop: () => void
|
||||
onPersistenceClear: () => void
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
@@ -24,6 +32,12 @@ export function SettingsPanel({
|
||||
debugMode,
|
||||
logCollection,
|
||||
logCount,
|
||||
persistenceEnabled,
|
||||
persistenceStartedAt,
|
||||
persistenceSessionCount,
|
||||
persistenceActionCount,
|
||||
open,
|
||||
onOpenChange,
|
||||
onHistoricalModeChange,
|
||||
onDebugModeChange,
|
||||
onLogCollectionChange,
|
||||
@@ -32,13 +46,15 @@ export function SettingsPanel({
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onRefresh,
|
||||
onPersistenceStart,
|
||||
onPersistenceStop,
|
||||
onPersistenceClear,
|
||||
}: SettingsPanelProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => onOpenChange(true)}
|
||||
className="p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
|
||||
>
|
||||
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
|
||||
@@ -52,7 +68,7 @@ export function SettingsPanel({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
@@ -73,7 +89,7 @@ export function SettingsPanel({
|
||||
SETTINGS
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-2 hover:bg-shell-800 rounded-lg transition-all"
|
||||
>
|
||||
<X size={18} className="text-gray-400" />
|
||||
@@ -171,6 +187,64 @@ export function SettingsPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Background Service */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<HardDrive size={18} className={persistenceEnabled ? 'text-neon-mint' : 'text-shell-500'} />
|
||||
<span className="font-display text-sm font-medium text-gray-200 uppercase tracking-wide">
|
||||
Background Service
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-console text-[10px] text-shell-500 mb-3">
|
||||
<span className="text-crab-600">></span> persist data across refreshes
|
||||
</p>
|
||||
|
||||
{persistenceEnabled && persistenceStartedAt && (
|
||||
<div className="font-console text-[10px] text-neon-mint mb-2">
|
||||
<span className="text-crab-600">></span> running since {new Date(persistenceStartedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="font-console text-[10px] text-shell-400 mb-3 space-y-1">
|
||||
<div>
|
||||
<span className="text-crab-600">></span> {persistenceSessionCount} sessions
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-crab-600">></span> {persistenceActionCount} actions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
{persistenceEnabled ? (
|
||||
<button
|
||||
onClick={onPersistenceStop}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 font-display text-xs uppercase tracking-wide bg-crab-600 hover:bg-crab-500 text-white rounded-lg transition-all"
|
||||
>
|
||||
<Square size={12} />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onPersistenceStart}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 font-display text-xs uppercase tracking-wide bg-neon-mint/20 hover:bg-neon-mint/30 text-neon-mint rounded-lg transition-all"
|
||||
>
|
||||
<Play size={12} />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onPersistenceClear}
|
||||
disabled={persistenceSessionCount === 0 && persistenceActionCount === 0}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 font-display text-xs uppercase tracking-wide bg-shell-800 hover:bg-crab-900/50 rounded-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Clear Stored Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Log collection */}
|
||||
<div className="panel-retro p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
|
||||
@@ -241,8 +241,9 @@ let clientInstance: ClawdbotClient | null = null
|
||||
|
||||
export function getClawdbotClient(): ClawdbotClient {
|
||||
if (!clientInstance) {
|
||||
const url = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
|
||||
const token = process.env.CLAWDBOT_API_TOKEN
|
||||
clientInstance = new ClawdbotClient('ws://127.0.0.1:18789', token)
|
||||
clientInstance = new ClawdbotClient(url, token)
|
||||
}
|
||||
return clientInstance
|
||||
}
|
||||
|
||||
@@ -67,10 +67,10 @@ export function addAction(action: MonitorAction) {
|
||||
const streamingId = `${action.runId}-stream`
|
||||
const existing = actionsCollection.state.get(streamingId)
|
||||
if (existing) {
|
||||
// Append content and update sessionKey if we learned it
|
||||
// Replace content (gateway sends cumulative text, not incremental deltas)
|
||||
actionsCollection.update(streamingId, (draft) => {
|
||||
if (action.content) {
|
||||
draft.content = (draft.content || '') + action.content
|
||||
draft.content = action.content
|
||||
}
|
||||
draft.seq = action.seq
|
||||
draft.timestamp = action.timestamp
|
||||
@@ -158,3 +158,24 @@ export function clearCollections() {
|
||||
actionsCollection.delete(action.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate collections from server persistence
|
||||
export function hydrateFromServer(
|
||||
sessions: MonitorSession[],
|
||||
actions: MonitorAction[]
|
||||
) {
|
||||
// First clear existing data
|
||||
clearCollections()
|
||||
|
||||
// Insert all sessions
|
||||
for (const session of sessions) {
|
||||
sessionsCollection.insert(session)
|
||||
}
|
||||
|
||||
// Replay actions through addAction to apply aggregation logic
|
||||
// Sort by timestamp to ensure correct order
|
||||
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
|
||||
for (const action of sortedActions) {
|
||||
addAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { MonitorSession, MonitorAction } from './protocol'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json')
|
||||
const ACTIONS_FILE = path.join(DATA_DIR, 'actions.jsonl')
|
||||
const STATE_FILE = path.join(DATA_DIR, 'state.json')
|
||||
const MAX_ACTIONS = 10000
|
||||
|
||||
interface PersistenceState {
|
||||
enabled: boolean
|
||||
startedAt: number | null
|
||||
}
|
||||
|
||||
class PersistenceService {
|
||||
private sessions: Map<string, MonitorSession> = new Map()
|
||||
private actions: MonitorAction[] = []
|
||||
private enabled = false
|
||||
private startedAt: number | null = null
|
||||
|
||||
constructor() {
|
||||
this.ensureDataDir()
|
||||
this.loadState()
|
||||
this.loadData()
|
||||
// Auto-start by default if no state file exists
|
||||
if (!this.enabled && !fs.existsSync(STATE_FILE)) {
|
||||
this.start()
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadState() {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PersistenceState
|
||||
this.enabled = data.enabled
|
||||
this.startedAt = data.startedAt
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private saveState() {
|
||||
const state: PersistenceState = {
|
||||
enabled: this.enabled,
|
||||
startedAt: this.startedAt,
|
||||
}
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
|
||||
}
|
||||
|
||||
private loadData() {
|
||||
// Load sessions
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')) as MonitorSession[]
|
||||
for (const session of data) {
|
||||
this.sessions.set(session.key, session)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Load actions (JSONL)
|
||||
try {
|
||||
if (fs.existsSync(ACTIONS_FILE)) {
|
||||
const content = fs.readFileSync(ACTIONS_FILE, 'utf-8')
|
||||
const lines = content.trim().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const action = JSON.parse(line) as MonitorAction
|
||||
this.actions.push(action)
|
||||
} catch {
|
||||
// skip bad lines
|
||||
}
|
||||
}
|
||||
// Trim to max if needed
|
||||
if (this.actions.length > MAX_ACTIONS) {
|
||||
this.actions = this.actions.slice(-MAX_ACTIONS)
|
||||
this.saveActions()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private saveSessions() {
|
||||
const data = Array.from(this.sessions.values())
|
||||
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
private saveActions() {
|
||||
const content = this.actions.map((a) => JSON.stringify(a)).join('\n')
|
||||
fs.writeFileSync(ACTIONS_FILE, content)
|
||||
}
|
||||
|
||||
private appendAction(action: MonitorAction) {
|
||||
fs.appendFileSync(ACTIONS_FILE, JSON.stringify(action) + '\n')
|
||||
}
|
||||
|
||||
get isEnabled() {
|
||||
return this.enabled
|
||||
}
|
||||
|
||||
start(): { enabled: boolean; startedAt: number } {
|
||||
this.enabled = true
|
||||
this.startedAt = Date.now()
|
||||
this.saveState()
|
||||
console.log('[persistence] started')
|
||||
return { enabled: true, startedAt: this.startedAt }
|
||||
}
|
||||
|
||||
stop(): { enabled: boolean } {
|
||||
this.enabled = false
|
||||
this.startedAt = null
|
||||
this.saveState()
|
||||
console.log('[persistence] stopped')
|
||||
return { enabled: false }
|
||||
}
|
||||
|
||||
getStatus(): {
|
||||
enabled: boolean
|
||||
startedAt: number | null
|
||||
sessionCount: number
|
||||
actionCount: number
|
||||
} {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
startedAt: this.startedAt,
|
||||
sessionCount: this.sessions.size,
|
||||
actionCount: this.actions.length,
|
||||
}
|
||||
}
|
||||
|
||||
upsertSession(session: MonitorSession) {
|
||||
if (!this.enabled) return
|
||||
this.sessions.set(session.key, session)
|
||||
this.saveSessions()
|
||||
}
|
||||
|
||||
addAction(action: MonitorAction) {
|
||||
if (!this.enabled) return
|
||||
|
||||
// Check if action already exists (by id)
|
||||
const existingIdx = this.actions.findIndex((a) => a.id === action.id)
|
||||
if (existingIdx >= 0) {
|
||||
// Update existing action
|
||||
this.actions[existingIdx] = action
|
||||
this.saveActions()
|
||||
} else {
|
||||
// Add new action
|
||||
this.actions.push(action)
|
||||
this.appendAction(action)
|
||||
|
||||
// Rotate if over limit
|
||||
if (this.actions.length > MAX_ACTIONS) {
|
||||
this.actions = this.actions.slice(-MAX_ACTIONS)
|
||||
this.saveActions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(): { sessions: MonitorSession[]; actions: MonitorAction[] } {
|
||||
return {
|
||||
sessions: Array.from(this.sessions.values()),
|
||||
actions: [...this.actions],
|
||||
}
|
||||
}
|
||||
|
||||
clear(): { cleared: boolean } {
|
||||
this.sessions.clear()
|
||||
this.actions = []
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) fs.unlinkSync(SESSIONS_FILE)
|
||||
if (fs.existsSync(ACTIONS_FILE)) fs.unlinkSync(ACTIONS_FILE)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
console.log('[persistence] cleared all data')
|
||||
return { cleared: true }
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: PersistenceService | null = null
|
||||
|
||||
export function getPersistenceService(): PersistenceService {
|
||||
if (!instance) {
|
||||
instance = new PersistenceService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export interface PresenceEntry {
|
||||
}
|
||||
|
||||
// Chat events
|
||||
// Note: gateway sends cumulative message content with each delta, not incremental chars
|
||||
export interface ChatEvent {
|
||||
runId: string
|
||||
sessionKey: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observable } from '@trpc/server/observable'
|
||||
import superjson from 'superjson'
|
||||
import { z } from 'zod'
|
||||
import { getClawdbotClient } from '~/integrations/clawdbot/client'
|
||||
import { getPersistenceService } from '~/integrations/clawdbot/persistence'
|
||||
import {
|
||||
parseEventFrame,
|
||||
sessionInfoToMonitor,
|
||||
@@ -114,14 +115,18 @@ const clawdbotRouter = router({
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const client = getClawdbotClient()
|
||||
const persistence = getPersistenceService()
|
||||
if (!client.connected) {
|
||||
return { sessions: [], error: 'Not connected' }
|
||||
}
|
||||
try {
|
||||
const sessions = await client.listSessions(input)
|
||||
return {
|
||||
sessions: sessions.map(sessionInfoToMonitor),
|
||||
const monitorSessions = sessions.map(sessionInfoToMonitor)
|
||||
// Persist sessions if service is enabled
|
||||
for (const session of monitorSessions) {
|
||||
persistence.upsertSession(session)
|
||||
}
|
||||
return { sessions: monitorSessions }
|
||||
} catch (error) {
|
||||
return {
|
||||
sessions: [],
|
||||
@@ -137,6 +142,7 @@ const clawdbotRouter = router({
|
||||
action?: MonitorAction
|
||||
}>((emit) => {
|
||||
const client = getClawdbotClient()
|
||||
const persistence = getPersistenceService()
|
||||
|
||||
const unsubscribe = client.onEvent((event) => {
|
||||
// Collect raw event when log collection is enabled
|
||||
@@ -161,6 +167,8 @@ const clawdbotRouter = router({
|
||||
emit.next({ type: 'session', session: parsed.session })
|
||||
}
|
||||
if (parsed.action) {
|
||||
// Persist action if service is enabled
|
||||
persistence.addAction(parsed.action)
|
||||
emit.next({ type: 'action', action: parsed.action })
|
||||
}
|
||||
}
|
||||
@@ -171,6 +179,32 @@ const clawdbotRouter = router({
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
// Persistence service
|
||||
persistenceStatus: publicProcedure.query(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.getStatus()
|
||||
}),
|
||||
|
||||
persistenceStart: publicProcedure.mutation(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.start()
|
||||
}),
|
||||
|
||||
persistenceStop: publicProcedure.mutation(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.stop()
|
||||
}),
|
||||
|
||||
persistenceHydrate: publicProcedure.query(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.hydrate()
|
||||
}),
|
||||
|
||||
persistenceClear: publicProcedure.mutation(() => {
|
||||
const persistence = getPersistenceService()
|
||||
return persistence.clear()
|
||||
}),
|
||||
})
|
||||
|
||||
export const appRouter = router({
|
||||
|
||||
@@ -122,7 +122,7 @@ function Home() {
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="font-console font-bold text-lg text-gray-400 mb-4 tracking-wide uppercase"
|
||||
>
|
||||
Open-Source Clawdbot Companion
|
||||
Open-Source Moltbot (Clawdbot) Companion
|
||||
</motion.p>
|
||||
|
||||
{/* Console-style description */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useLiveQuery } from '@tanstack/react-db'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, HardDrive } from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import {
|
||||
sessionsCollection,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
addAction,
|
||||
updateSessionStatus,
|
||||
clearCollections,
|
||||
hydrateFromServer,
|
||||
} from '~/integrations/clawdbot'
|
||||
import {
|
||||
ActionGraph,
|
||||
@@ -67,9 +68,18 @@ function MonitorPage() {
|
||||
const [logCount, setLogCount] = useState(0)
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
|
||||
// Persistence service state
|
||||
const [persistenceEnabled, setPersistenceEnabled] = useState(false)
|
||||
const [persistenceStartedAt, setPersistenceStartedAt] = useState<number | null>(null)
|
||||
const [persistenceSessionCount, setPersistenceSessionCount] = useState(0)
|
||||
const [persistenceActionCount, setPersistenceActionCount] = useState(0)
|
||||
|
||||
// Sidebar collapse state - default to collapsed
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
||||
|
||||
// Settings panel state
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Live queries from TanStack DB collections
|
||||
const sessionsQuery = useLiveQuery(sessionsCollection)
|
||||
const actionsQuery = useLiveQuery(actionsCollection)
|
||||
@@ -78,11 +88,24 @@ function MonitorPage() {
|
||||
const actions = actionsQuery.data ?? []
|
||||
|
||||
|
||||
// Check connection status on mount
|
||||
// Check connection status and persistence on mount
|
||||
useEffect(() => {
|
||||
checkStatus()
|
||||
checkPersistenceStatus()
|
||||
}, [])
|
||||
|
||||
const checkPersistenceStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.status.query()
|
||||
@@ -101,6 +124,8 @@ function MonitorPage() {
|
||||
setConnected(true)
|
||||
setRetryCount(0)
|
||||
setConnecting(false)
|
||||
// Hydrate from persistence if enabled
|
||||
await hydrateFromPersistence()
|
||||
await loadSessions()
|
||||
return
|
||||
}
|
||||
@@ -115,6 +140,23 @@ function MonitorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateFromPersistence = async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
if (status.sessionCount > 0 || status.actionCount > 0) {
|
||||
const data = await trpc.clawdbot.persistenceHydrate.query()
|
||||
hydrateFromServer(data.sessions, data.actions)
|
||||
console.log(`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions`)
|
||||
}
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch (e) {
|
||||
console.error('Failed to hydrate:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.disconnect.mutate()
|
||||
@@ -196,6 +238,37 @@ function MonitorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistenceStart = async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.persistenceStart.mutate()
|
||||
setPersistenceEnabled(result.enabled)
|
||||
setPersistenceStartedAt(result.startedAt)
|
||||
} catch (e) {
|
||||
console.error('Failed to start persistence:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistenceStop = async () => {
|
||||
try {
|
||||
const result = await trpc.clawdbot.persistenceStop.mutate()
|
||||
setPersistenceEnabled(result.enabled)
|
||||
setPersistenceStartedAt(null)
|
||||
} catch (e) {
|
||||
console.error('Failed to stop persistence:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistenceClear = async () => {
|
||||
try {
|
||||
await trpc.clawdbot.persistenceClear.mutate()
|
||||
setPersistenceSessionCount(0)
|
||||
setPersistenceActionCount(0)
|
||||
clearCollections()
|
||||
} catch (e) {
|
||||
console.error('Failed to clear persistence:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll log count while collecting
|
||||
useEffect(() => {
|
||||
if (!logCollection) return
|
||||
@@ -210,6 +283,22 @@ function MonitorPage() {
|
||||
return () => clearInterval(interval)
|
||||
}, [logCollection])
|
||||
|
||||
// Poll persistence status
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const status = await trpc.clawdbot.persistenceStatus.query()
|
||||
setPersistenceEnabled(status.enabled)
|
||||
setPersistenceStartedAt(status.startedAt)
|
||||
setPersistenceSessionCount(status.sessionCount)
|
||||
setPersistenceActionCount(status.actionCount)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleToggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed((prev) => !prev)
|
||||
}, [])
|
||||
@@ -294,6 +383,25 @@ function MonitorPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Persistence indicator */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all ${
|
||||
persistenceEnabled
|
||||
? 'bg-neon-mint/10 hover:bg-neon-mint/20'
|
||||
: 'bg-shell-800/50 hover:bg-shell-700'
|
||||
}`}
|
||||
title={persistenceEnabled ? 'Background service running' : 'Background service stopped'}
|
||||
>
|
||||
<HardDrive
|
||||
size={14}
|
||||
className={persistenceEnabled ? 'text-neon-mint' : 'text-shell-500'}
|
||||
/>
|
||||
{persistenceEnabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-neon-mint animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stats display */}
|
||||
<div className="hidden sm:flex items-center gap-3 px-3 py-1.5 bg-shell-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -313,6 +421,12 @@ function MonitorPage() {
|
||||
debugMode={debugMode}
|
||||
logCollection={logCollection}
|
||||
logCount={logCount}
|
||||
persistenceEnabled={persistenceEnabled}
|
||||
persistenceStartedAt={persistenceStartedAt}
|
||||
persistenceSessionCount={persistenceSessionCount}
|
||||
persistenceActionCount={persistenceActionCount}
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
onHistoricalModeChange={handleHistoricalModeChange}
|
||||
onDebugModeChange={handleDebugModeChange}
|
||||
onLogCollectionChange={handleLogCollectionChange}
|
||||
@@ -321,6 +435,9 @@ function MonitorPage() {
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onRefresh={handleRefresh}
|
||||
onPersistenceStart={handlePersistenceStart}
|
||||
onPersistenceStop={handlePersistenceStop}
|
||||
onPersistenceClear={handlePersistenceClear}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user