feat: initial Crablo Control Center dashboard

This commit is contained in:
Crablo
2026-05-08 16:05:52 +00:00
commit fd55845dfa
22 changed files with 3565 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+6
View File
@@ -0,0 +1,6 @@
{
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.2 create --template minimal --types ts --add tailwindcss="plugins:none" sveltekit-adapter="adapter:node" --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+3043
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "crablo-dashboard",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.59.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"svelte": "^5.55.5",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.3.2",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^5.4.21"
},
"type": "module"
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import './layout.css';
import { page } from '$app/state';
</script>
<div class="shell">
<nav>
<a href="/" class="brand">🦀 crablo</a>
<div class="nav-links">
<a href="/" class:active={page.url.pathname === '/'}>issues</a>
<a href="/agents" class:active={page.url.pathname === '/agents'}>agents</a>
<a href="/life" class:active={page.url.pathname === '/life'}>life</a>
</div>
</nav>
<main>
<slot />
</main>
</div>
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from 'fs';
import { join } from 'path';
interface Issue {
title: string;
status: 'open' | 'resolved';
firstSeen?: string;
symptom?: string;
workaround?: string;
realFix?: string;
resolvedNote?: string;
}
function parseIssues(md: string): Issue[] {
const issues: Issue[] = [];
// Split on ### headers
const sections = md.split(/\n### /);
for (const section of sections.slice(1)) {
const lines = section.split('\n');
const title = lines[0].trim();
const body = lines.slice(1).join('\n');
const get = (key: string) => {
const m = body.match(new RegExp(`\\*\\*${key}\\*\\*:\\s*(.+)`));
return m ? m[1].replace(/\[\[.*?\]\]/g, s => s.replace(/\[\[.*?\/([^\]|]+).*?\]\]/, '$1')).trim() : undefined;
};
const isResolved = md.includes('## Resolved') &&
md.indexOf('### ' + title) > md.indexOf('## Resolved');
issues.push({
title,
status: isResolved ? 'resolved' : 'open',
firstSeen: get('First seen'),
symptom: get('Symptom'),
workaround: get('Workaround'),
realFix: get('Real fix') || get('Fix'),
});
}
return issues;
}
export async function load() {
let issues: Issue[] = [];
try {
const md = readFileSync(
join(process.env.HOME || '/data/workspace', 'workspace/crablo/memory/active-issues.md'),
'utf-8'
);
issues = parseIssues(md);
} catch (e) {
// file not found or parse error
}
return { issues };
}
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
let { data } = $props<{ data: { issues: Array<{
title: string; status: string; firstSeen?: string;
symptom?: string; workaround?: string; realFix?: string;
}> } }>();
let open = $derived(data.issues.filter((i: any) => i.status === 'open'));
let resolved = $derived(data.issues.filter((i: any) => i.status === 'resolved'));
</script>
<svelte:head><title>crablo // issues</title></svelte:head>
<h1>Active Issues <span class="dim">({open.length} open)</span></h1>
{#each open as issue}
<div class="card">
<div style="display:flex; align-items:center; gap:0.75rem; margin-bottom:0.6rem;">
<span class="tag open">open</span>
<span style="font-weight:500; font-size:0.95rem;">{issue.title}</span>
{#if issue.firstSeen}
<span class="dim" style="font-size:0.75rem; margin-left:auto;">{issue.firstSeen}</span>
{/if}
</div>
{#if issue.symptom}
<div class="label">symptom</div>
<div class="value" style="margin-bottom:0.5rem;">{issue.symptom}</div>
{/if}
{#if issue.workaround}
<div class="label">workaround</div>
<div class="value dim" style="margin-bottom:0.5rem;">{issue.workaround}</div>
{/if}
{#if issue.realFix}
<div class="label">real fix</div>
<div class="value" style="color: var(--amber);">{issue.realFix}</div>
{/if}
</div>
{/each}
{#if resolved.length > 0}
<details style="margin-top:1.5rem;">
<summary>Resolved ({resolved.length})</summary>
<div style="margin-top:0.75rem;">
{#each resolved as issue}
<div class="card">
<span class="tag resolved">resolved</span>
<span style="font-size:0.9rem;">{issue.title}</span>
</div>
{/each}
</div>
</details>
{/if}
{#if data.issues.length === 0}
<div class="dim" style="font-size:0.85rem; padding: 2rem 0;">no issues found — either everything's fine or the file didn't parse</div>
{/if}
+12
View File
@@ -0,0 +1,12 @@
import { readFileSync } from 'fs';
export async function load() {
let agents: any[] = [];
let defaults: any = {};
try {
const cfg = JSON.parse(readFileSync('/data/.openclaw/openclaw.json', 'utf-8'));
agents = cfg?.agents?.list || [];
defaults = cfg?.agents?.defaults || {};
} catch (e) {}
return { agents, defaults };
}
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
let { data } = $props<{ data: { agents: any[]; defaults: any } }>();
const thinking: Record<string, string> = { off: '—', low: '🔅', medium: '🔆', high: '🔆🔆', adaptive: '~' };
</script>
<svelte:head><title>crablo // agents</title></svelte:head>
<h1>Agents <span class="dim">({data.agents.length})</span></h1>
<div class="grid-2">
{#each data.agents as agent}
<div class="card">
<div style="display:flex; align-items:baseline; gap:0.6rem; margin-bottom:0.6rem;">
<span style="font-weight:600; color:var(--amber);">{agent.name || agent.id}</span>
{#if agent.default}<span class="tag warn">default</span>{/if}
{#if agent.id !== (agent.name || agent.id)}<span class="dim" style="font-size:0.75rem;">{agent.id}</span>{/if}
</div>
<div class="label">primary model</div>
<div class="value" style="margin-bottom:0.4rem;">
<span class="pill primary">{agent.model?.primary || data.defaults?.model?.primary || '—'}</span>
</div>
{#if agent.model?.fallbacks?.length}
<div class="label">fallbacks</div>
<div style="margin-bottom:0.4rem;">
{#each agent.model.fallbacks as fb}
<span class="pill">{fb}</span>
{/each}
</div>
{/if}
{#if agent.thinkingDefault}
<div class="label">thinking</div>
<div class="value">{thinking[agent.thinkingDefault] || agent.thinkingDefault}</div>
{/if}
</div>
{/each}
</div>
<div style="margin-top:2rem;">
<h2>Default fallback chain</h2>
<div style="margin-top:0.5rem;">
{#each (data.defaults?.model?.fallbacks || []) as fb, i}
<span class="pill">{i+1}. {fb}</span>
{/each}
</div>
</div>
+76
View File
@@ -0,0 +1,76 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #080810;
--bg2: #0f0f1a;
--bg3: #16162a;
--border: #1e1e3a;
--amber: #f5a623;
--amber-dim: #7a5210;
--green: #39ff85;
--green-dim: #1a5c3a;
--red: #ff4455;
--red-dim: #5c1a20;
--blue: #4488ff;
--text: #d4d4e8;
--text-dim: #6666aa;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}
html, body { background: var(--bg); color: var(--text); font-family: var(--mono); height: 100%; }
.shell { display: flex; flex-direction: column; min-height: 100vh; }
nav {
display: flex; align-items: center; gap: 2rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--bg2);
position: sticky; top: 0; z-index: 10;
}
.brand { color: var(--amber); font-weight: 600; font-size: 1rem; text-decoration: none; letter-spacing: 0.05em; }
.nav-links { display: flex; gap: 1.5rem; }
.nav-links a { color: var(--text-dim); text-decoration: none; font-size: 0.85rem; letter-spacing: 0.08em; text-transform: uppercase; transition: color 0.15s; }
.nav-links a:hover, .nav-links a.active { color: var(--amber); }
main { flex: 1; padding: 2rem 1.5rem; max-width: 960px; margin: 0 auto; width: 100%; }
h1 { font-size: 1.1rem; font-weight: 600; color: var(--amber); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 1.5rem; }
h2 { font-size: 0.85rem; font-weight: 500; color: var(--text-dim); letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 1rem; }
.card {
background: var(--bg2); border: 1px solid var(--border);
padding: 1rem 1.25rem; margin-bottom: 0.75rem;
}
.card:hover { border-color: var(--amber-dim); }
.tag {
display: inline-block; font-size: 0.7rem; padding: 0.15rem 0.5rem;
border: 1px solid; letter-spacing: 0.08em; text-transform: uppercase; margin-right: 0.4rem;
}
.tag.open { color: var(--red); border-color: var(--red-dim); background: rgba(255,68,85,0.06); }
.tag.resolved { color: var(--green); border-color: var(--green-dim); background: rgba(57,255,133,0.06); }
.tag.warn { color: var(--amber); border-color: var(--amber-dim); background: rgba(245,166,35,0.06); }
.label { font-size: 0.72rem; color: var(--text-dim); letter-spacing: 0.06em; margin-bottom: 0.25rem; }
.value { font-size: 0.88rem; color: var(--text); }
.dim { color: var(--text-dim); }
details > summary { cursor: pointer; list-style: none; color: var(--text-dim); font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 0.5rem 0; }
details > summary::before { content: '▶ '; }
details[open] > summary::before { content: '▼ '; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
@media (max-width: 600px) { .grid-2 { grid-template-columns: 1fr; } }
.pill {
display: inline-block; font-size: 0.68rem; padding: 0.1rem 0.45rem;
background: var(--bg3); border: 1px solid var(--border); color: var(--text-dim);
letter-spacing: 0.05em; margin-right: 0.3rem; margin-bottom: 0.2rem;
}
.pill.primary { color: var(--amber); border-color: var(--amber-dim); }
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
const fitness = [
{ goal: 'Sign up for climbing gym', note: 'Miss it. Good cardio + social.', status: 'todo' },
{ goal: 'Regular cardio', note: 'Goal: a few times a week', status: 'todo' },
{ goal: 'Lifting', note: 'Get a little more jacked.', status: 'todo' },
];
const recipes = [
{
name: 'Dandan Noodles',
note: 'Had as takeout. Need to make it again.',
ingredients: 'Ramen noodles, peanut butter sauce, ground beef, bok choy',
tags: ['easy', 'cheap', 'satisfying', 'noodles'],
status: 'want-to-make',
},
];
const health = [
{ label: 'Blood pressure', value: '~140/70', note: 'Hypertension. Primary motivation for fitness + lifestyle changes.' },
{ label: 'Oura ring', value: 'connected', note: 'Biometrics available. Explore HRV, sleep, readiness over time.' },
];
const farmshare = { day: 'Wednesday evenings', note: 'Plan weekly recipes around what comes in.' };
</script>
<svelte:head><title>crablo // life</title></svelte:head>
<h1>Life</h1>
<section style="margin-bottom:2rem;">
<h2>🧗 Fitness</h2>
{#each fitness as item}
<div class="card" style="display:flex; gap:1rem; align-items:flex-start;">
<span style="color: {item.status === 'done' ? 'var(--green)' : 'var(--text-dim)'}; font-size:1.1rem;">
{item.status === 'done' ? '✓' : '○'}
</span>
<div>
<div class="value">{item.goal}</div>
{#if item.note}<div class="dim" style="font-size:0.8rem; margin-top:0.2rem;">{item.note}</div>{/if}
</div>
</div>
{/each}
</section>
<section style="margin-bottom:2rem;">
<h2>🍜 Cooking</h2>
<div class="card" style="margin-bottom:1rem; border-color: var(--amber-dim);">
<div class="label">farmshare</div>
<div class="value">{farmshare.day}<span class="dim">{farmshare.note}</span></div>
</div>
{#each recipes as r}
<div class="card">
<div style="display:flex; align-items:center; gap:0.75rem; margin-bottom:0.5rem;">
<span class="tag warn">{r.status}</span>
<span style="font-weight:500;">{r.name}</span>
</div>
<div class="label">ingredients</div>
<div class="value" style="margin-bottom:0.4rem;">{r.ingredients}</div>
{#if r.note}<div class="dim" style="font-size:0.8rem;">{r.note}</div>{/if}
<div style="margin-top:0.5rem;">
{#each r.tags as tag}<span class="pill">{tag}</span>{/each}
</div>
</div>
{/each}
</section>
<section style="margin-bottom:2rem;">
<h2>❤️ Health</h2>
{#each health as item}
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:0.3rem;">
<span class="value">{item.label}</span>
<span style="color:var(--amber); font-size:0.85rem;">{item.value}</span>
</div>
<div class="dim" style="font-size:0.8rem;">{item.note}</div>
</div>
{/each}
</section>
<section>
<h2>📋 On the list</h2>
{#each ['Clean the room', 'Start job search'] as item}
<div class="card">
<span style="color:var(--text-dim);"></span>
<span style="margin-left:0.75rem;">{item}</span>
</div>
{/each}
</section>
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
kit: { adapter: adapter() }
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+4
View File
@@ -0,0 +1,4 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [sveltekit()] });