mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-29 13:04:51 +02:00
[hAIk]: migrate dashboard layout thunks to backend-bridge: replace legacy fetchLayout with GET_DASHBOARD and saveLayout with UPDATE_DASHBOARD, move browser-card tab normalization into slice extraReducers, extend Dashboard interface with layout type, remove dashboardLayoutThunks.ts and unused LayoutPayload/SaveLayoutPayload types. Also removed UsageStats and DiffViewer
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { SUBSCRIPTIONS_STATUS } from '@/shared/backend-bridge/apps/subscriptions';
|
||||
import { CREATE_TOOL, OAUTH_START, GET_TOOL, DISCOVER_TOOL } from '@/shared/backend-bridge/apps/tools';
|
||||
import type { ToolDefinition } from '@/shared/state/toolsSlice';
|
||||
import { ToolIntegration } from './onboardingConstants';
|
||||
import { useSubscriptionConnect } from './useSubscriptionConnect';
|
||||
|
||||
@@ -76,58 +77,55 @@ export function useOnboarding() {
|
||||
const handleToolConnect = useCallback(async (integration: ToolIntegration) => {
|
||||
setConnecting(integration.name);
|
||||
try {
|
||||
const createRes = await fetch(`${API_BASE}/tools/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: integration.name,
|
||||
description: integration.desc,
|
||||
mcp_config: integration.mcp_config,
|
||||
auth_type: 'oauth2',
|
||||
auth_status: 'configured',
|
||||
oauth_provider: integration.oauthProvider,
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) { setConnecting(null); return; }
|
||||
const { tool } = await createRes.json();
|
||||
const createResult = await dispatch(CREATE_TOOL({
|
||||
name: integration.name,
|
||||
description: integration.desc,
|
||||
mcp_config: integration.mcp_config,
|
||||
auth_type: 'oauth2',
|
||||
auth_status: 'configured',
|
||||
oauth_provider: integration.oauthProvider,
|
||||
}));
|
||||
if (!CREATE_TOOL.fulfilled.match(createResult)) { setConnecting(null); return; }
|
||||
const tool = createResult.payload.tool as unknown as ToolDefinition;
|
||||
|
||||
const oauthRes = await fetch(`${API_BASE}/tools/${tool.id}/oauth/start`, { method: 'POST' });
|
||||
if (!oauthRes.ok) { setConnecting(null); return; }
|
||||
const { auth_url } = await oauthRes.json();
|
||||
const oauthResult = await dispatch(OAUTH_START(tool.id));
|
||||
if (!OAUTH_START.fulfilled.match(oauthResult)) { setConnecting(null); return; }
|
||||
const { auth_url } = oauthResult.payload;
|
||||
|
||||
const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100');
|
||||
|
||||
const afterConnect = async () => {
|
||||
const statusResult = await dispatch(GET_TOOL(tool.id));
|
||||
if (
|
||||
GET_TOOL.fulfilled.match(statusResult) &&
|
||||
(statusResult.payload as unknown as ToolDefinition).auth_status === 'connected'
|
||||
) {
|
||||
setConnectedTools((prev) => new Set(prev).add(integration.name));
|
||||
dispatch(DISCOVER_TOOL(tool.id));
|
||||
}
|
||||
setConnecting(null);
|
||||
};
|
||||
|
||||
const onMsg = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) {
|
||||
window.removeEventListener('message', onMsg);
|
||||
setConnectedTools((prev) => new Set(prev).add(integration.name));
|
||||
setConnecting(null);
|
||||
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
|
||||
afterConnect();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
|
||||
const poller = setInterval(() => {
|
||||
if (popup && popup.closed) {
|
||||
if (popup?.closed) {
|
||||
clearInterval(poller);
|
||||
window.removeEventListener('message', onMsg);
|
||||
fetch(`${API_BASE}/tools/${tool.id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.tool?.auth_status === 'connected') {
|
||||
setConnectedTools((prev) => new Set(prev).add(integration.name));
|
||||
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
setConnecting(null);
|
||||
afterConnect();
|
||||
}
|
||||
}, 1000);
|
||||
setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000);
|
||||
} catch {
|
||||
setConnecting(null);
|
||||
}
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleApiKey = useCallback(() => advanceToTools(), [advanceToTools]);
|
||||
const handleSkip = useCallback(() => dismiss(), [dismiss]);
|
||||
|
||||
@@ -4,7 +4,6 @@ import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DiffViewer from './DiffViewer';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
@@ -17,11 +16,10 @@ interface ChatHeaderProps {
|
||||
id: string;
|
||||
};
|
||||
isDraft: boolean;
|
||||
id: string | undefined;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const ChatHeader: React.FC<ChatHeaderProps> = ({ session, isDraft, id, onClose }) => {
|
||||
const ChatHeader: React.FC<ChatHeaderProps> = ({ session, isDraft, onClose }) => {
|
||||
const c = useClaudeTokens();
|
||||
const STATUS_STYLES: Record<string, { color: string; bg: string }> = {
|
||||
running: { color: c.status.success, bg: c.status.successBg },
|
||||
@@ -73,7 +71,6 @@ const ChatHeader: React.FC<ChatHeaderProps> = ({ session, isDraft, id, onClose }
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!isDraft && id && <DiffViewer sessionId={id} />}
|
||||
{onClose && (
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import { CodeDiff } from '@/components/tool-ui/code-diff/code-diff';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const AGENTS_API = `${API_BASE}/agents`;
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const DiffViewer: React.FC<Props> = ({ sessionId }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [diff, setDiff] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const fetchDiff = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/diff`);
|
||||
const data = await res.json();
|
||||
setDiff(data.diff || '');
|
||||
} catch {
|
||||
setDiff('Failed to fetch diff');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchDiff();
|
||||
}, [open, sessionId]);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Tooltip title="View changes">
|
||||
<IconButton onClick={() => setOpen(true)} sx={{ color: c.text.tertiary }}>
|
||||
<DifferenceIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 400,
|
||||
flexShrink: 0,
|
||||
boxShadow: '-1px 0 4px rgba(0,0,0,0.04)',
|
||||
bgcolor: c.bg.surface,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: `0.5px solid ${c.border.medium}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
Worktree Changes
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton size="small" onClick={fetchDiff} sx={{ color: c.text.tertiary }}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={() => setOpen(false)} sx={{ color: c.text.tertiary }}>
|
||||
<Typography sx={{ fontSize: '0.85rem' }}>×</Typography>
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
p: 1.5,
|
||||
'&::-webkit-scrollbar': { width: 5, height: 5 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: c.border.medium,
|
||||
borderRadius: 3,
|
||||
'&:hover': { background: c.border.strong },
|
||||
},
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Loading...</Typography>
|
||||
) : diff ? (
|
||||
<CodeDiff
|
||||
id={`diff-${sessionId}`}
|
||||
patch={diff}
|
||||
language="diff"
|
||||
diffStyle="unified"
|
||||
lineNumbers="visible"
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>
|
||||
No changes detected in the worktree.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffViewer;
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { toggleExpandSession } from '@/shared/state/agentsSlice';
|
||||
import { saveLayout } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { UPDATE_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
|
||||
import { useCanvasControls } from './useCanvasControls';
|
||||
import { useDashboardSelection } from './useDashboardSelection';
|
||||
import { ElementSelectionProvider, useElementSelection } from '@/app/components/ElementSelectionContext';
|
||||
@@ -170,15 +170,23 @@ const DashboardInner: React.FC = () => {
|
||||
|
||||
const skipInitialSave = useRef(true);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingSaveRef = useRef<Parameters<typeof saveLayout>[0] | null>(null);
|
||||
const pendingSaveRef = useRef<Parameters<typeof UPDATE_DASHBOARD>[0] | null>(null);
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized || !dashboardId) return;
|
||||
if (skipInitialSave.current) { skipInitialSave.current = false; return; }
|
||||
const payload = { dashboardId, cards, viewCards, browserCards, expandedSessionIds };
|
||||
const payload = {
|
||||
dashboardId,
|
||||
layout: {
|
||||
cards,
|
||||
view_cards: viewCards,
|
||||
browser_cards: browserCards,
|
||||
expanded_session_ids: expandedSessionIds,
|
||||
},
|
||||
};
|
||||
pendingSaveRef.current = payload;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
dispatch(saveLayout(payload));
|
||||
dispatch(UPDATE_DASHBOARD(payload));
|
||||
pendingSaveRef.current = null;
|
||||
saveTimerRef.current = null;
|
||||
captureNow();
|
||||
@@ -188,7 +196,7 @@ const DashboardInner: React.FC = () => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; }
|
||||
if (pendingSaveRef.current) { dispatch(saveLayout(pendingSaveRef.current)); pendingSaveRef.current = null; }
|
||||
if (pendingSaveRef.current) { dispatch(UPDATE_DASHBOARD(pendingSaveRef.current)); pendingSaveRef.current = null; }
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useAppDispatch } from '@/shared/hooks';
|
||||
import { store } from '@/shared/state/store';
|
||||
import { setExpandedSessionIds } from '@/shared/state/agentsSlice';
|
||||
import { GET_ALL_SESSIONS, GET_HISTORY } from '@/shared/backend-bridge/apps/agents';
|
||||
import { GET_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
|
||||
import {
|
||||
fetchLayout,
|
||||
resetLayout,
|
||||
reconcileSessions,
|
||||
addBrowserCard,
|
||||
@@ -51,7 +51,7 @@ export function useDashboardInit(deps: InitDeps) {
|
||||
dispatch(resetLayout());
|
||||
dispatch(GET_ALL_SESSIONS(dashboardId));
|
||||
dispatch(GET_HISTORY({}));
|
||||
dispatch(fetchLayout(dashboardId));
|
||||
dispatch(GET_DASHBOARD(dashboardId));
|
||||
dispatch(LIST_APPS());
|
||||
dashboardWs.connect();
|
||||
const cleanupBrowserHandler = initBrowserCommandHandler();
|
||||
|
||||
@@ -17,13 +17,12 @@ import { CommandsContent } from '@/app/pages/Commands/Commands';
|
||||
import { useSettings } from './hooks/useSettings';
|
||||
import GeneralTab from './GeneralTab';
|
||||
import ModelsTab from './ModelsTab';
|
||||
import UsageStats from './UsageStats';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const s = useSettings();
|
||||
const { c, open, activeTab, setActiveTab, hasChanges, handleSave, handleRequestClose,
|
||||
confirmDiscard, setConfirmDiscard, handleConfirmDiscard, handleSaveAndClose,
|
||||
saved, setSaved, form, setForm } = s;
|
||||
saved, setSaved } = s;
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -76,7 +75,6 @@ const Settings: React.FC = () => {
|
||||
>
|
||||
<Tab label="General" value="general" disableRipple />
|
||||
<Tab label="Models" value="models" disableRipple />
|
||||
<Tab label="Usage" value="usage" disableRipple />
|
||||
<Tab label="Commands" value="commands" disableRipple />
|
||||
</Tabs>
|
||||
</DialogTitle>
|
||||
@@ -93,10 +91,6 @@ const Settings: React.FC = () => {
|
||||
<GeneralTab s={s} />
|
||||
) : activeTab === 'models' ? (
|
||||
<ModelsTab s={s} />
|
||||
) : activeTab === 'usage' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<UsageStats />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ pt: 2.5, pb: 1, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
<CommandsContent />
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
|
||||
const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
|
||||
|
||||
const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => {
|
||||
const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: '1px', mt: 0.25 }}>
|
||||
{Array.from({ length: width }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
bgcolor: i < filled
|
||||
? palette[Math.min(palette.length - 1, Math.floor((i / Math.max(filled - 1, 1)) * (palette.length - 1)))]
|
||||
: c.border.subtle,
|
||||
opacity: i < filled ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const UsageStats: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/analytics/usage-summary`)
|
||||
.then(r => r.json())
|
||||
.then(setStats)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
if (!stats) {
|
||||
const skeletonPulse = {
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
|
||||
};
|
||||
const skeletonCard = {
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`, ...skeletonPulse,
|
||||
};
|
||||
return (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Box key={i} sx={skeletonCard}>
|
||||
<Box sx={{ width: 60, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
|
||||
<Box sx={{ width: 50, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 90, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Box key={i} sx={skeletonCard}>
|
||||
<Box sx={{ width: 70, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
|
||||
<Box sx={{ width: 45, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Box key={i} sx={{ ...skeletonCard, p: 2 }}>
|
||||
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 2 }} />
|
||||
{Array.from({ length: 3 }, (_, j) => (
|
||||
<Box key={j} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box sx={{ width: 60 + j * 15, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
<Box sx={{ width: 35, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: '1px' }}>
|
||||
{Array.from({ length: 16 }, (_, k) => (
|
||||
<Box key={k} sx={{ width: 5, height: 5, bgcolor: c.border.subtle, opacity: k < 8 - j * 2 ? 0.6 : 0.2 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const formatCost = (v: number) => {
|
||||
if (v === 0) return '$0.00';
|
||||
if (v < 0.001) return `$${v.toFixed(6)}`;
|
||||
if (v < 0.01) return `$${v.toFixed(5)}`;
|
||||
if (v < 1) return `$${v.toFixed(4)}`;
|
||||
return `$${v.toFixed(2)}`;
|
||||
};
|
||||
const formatDuration = (s: number) => {
|
||||
if (s === 0) return '0s';
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
};
|
||||
const formatTotalTime = (s: number) => {
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
if (s < 3600) return `${(s / 60).toFixed(1)} min`;
|
||||
return `${(s / 3600).toFixed(1)} hrs`;
|
||||
};
|
||||
const cardSx = {
|
||||
p: 1.5,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
};
|
||||
const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 };
|
||||
const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 };
|
||||
const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 };
|
||||
const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
|
||||
const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
|
||||
const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][];
|
||||
const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
|
||||
const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
|
||||
const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
|
||||
<PixelBarOuter {...props} tokens={c} />
|
||||
);
|
||||
const totalTime = stats.avg_duration_seconds * stats.total_sessions;
|
||||
const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0';
|
||||
const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0';
|
||||
const formatTokens = (n: number) => {
|
||||
if (n === 0) return '0';
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
};
|
||||
const isSubscription = stats.cost_source === '9router';
|
||||
const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : '';
|
||||
return (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Sessions</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_sessions.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>{isSubscription ? 'You Saved' : 'Total Cost'}</Typography>
|
||||
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{isSubscription
|
||||
? `${formatCost(stats.avg_cost_per_session)} avg · saved with your subscription`
|
||||
: costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Messages</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_messages.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>{msgsPerSession} avg per session</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Tool Calls</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_tool_calls.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>{toolsPerSession} avg per session</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Run Time</Typography>
|
||||
<Typography sx={valueSx}>{formatTotalTime(totalTime)}</Typography>
|
||||
<Typography sx={subSx}>across all sessions</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Avg Session</Typography>
|
||||
<Typography sx={valueSx}>{formatDuration(stats.avg_duration_seconds)}</Typography>
|
||||
<Typography sx={subSx}>per session duration</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Completion Rate</Typography>
|
||||
<Typography sx={valueSx}>{(stats.completion_rate * 100).toFixed(1)}%</Typography>
|
||||
<Typography sx={subSx}>sessions finished successfully</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Tokens Used</Typography>
|
||||
<Typography sx={valueSx}>
|
||||
{stats.total_prompt_tokens || stats.total_completion_tokens
|
||||
? formatTokens((stats.total_prompt_tokens || 0) + (stats.total_completion_tokens || 0))
|
||||
: Object.keys(stats.providers_used || {}).length}
|
||||
</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{stats.total_prompt_tokens || stats.total_completion_tokens
|
||||
? `${formatTokens(stats.total_prompt_tokens || 0)} in · ${formatTokens(stats.total_completion_tokens || 0)} out`
|
||||
: providerEntries.map(([p]) => p).join(', ') || 'none'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<Box sx={{ ...cardSx, p: 2 }}>
|
||||
<Typography sx={{ ...labelSx, mb: 1.5 }}>Models Used</Typography>
|
||||
{modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
|
||||
const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
|
||||
return (
|
||||
<Box key={model} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, fontWeight: 500 }}>{model}</Typography>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
|
||||
{count} ({pct}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<PixelBar value={count} max={stats.total_sessions} palette={PIXEL_BLUE} />
|
||||
</Box>
|
||||
);
|
||||
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No sessions yet</Typography>}
|
||||
</Box>
|
||||
<Box sx={{ ...cardSx, p: 2 }}>
|
||||
<Typography sx={{ ...labelSx, mb: 1.5 }}>Top Tools</Typography>
|
||||
{toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
|
||||
const shortName = tool.includes('__') ? tool.split('__').pop() : tool;
|
||||
const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0';
|
||||
return (
|
||||
<Box key={tool} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, fontWeight: 500 }}>{shortName}</Typography>
|
||||
<Typography sx={{ fontSize: '0.62rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
|
||||
{count} call{count !== 1 ? 's' : ''} ({pct}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<PixelBar value={count} max={maxToolCount} />
|
||||
</Box>
|
||||
);
|
||||
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No tool calls yet</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageStats;
|
||||
@@ -3,6 +3,13 @@ import { API_BASE } from '@/shared/backend-bridge/base_routes';
|
||||
|
||||
const DASHBOARDS_API: string = `${API_BASE}/dashboards`;
|
||||
|
||||
export interface DashboardLayout {
|
||||
cards?: Record<string, unknown>;
|
||||
view_cards?: Record<string, unknown>;
|
||||
browser_cards?: Record<string, unknown>;
|
||||
expanded_session_ids?: string[];
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -10,6 +17,7 @@ export interface Dashboard {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
thumbnail?: string | null;
|
||||
layout?: DashboardLayout;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { initialState } from './dashboardLayoutTypes';
|
||||
import { initialState, generateTabId } from './dashboardLayoutTypes';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition } from './dashboardLayoutTypes';
|
||||
import { dashboardLayoutReducers } from './dashboardLayoutReducers';
|
||||
import { fetchLayout } from './dashboardLayoutThunks';
|
||||
import { GET_DASHBOARD } from '@/shared/backend-bridge/apps/dashboards';
|
||||
import { META_LAUNCH_AND_SEND } from '@/shared/backend-bridge/apps/agents';
|
||||
|
||||
const dashboardLayoutSlice = createSlice({
|
||||
@@ -10,16 +11,31 @@ const dashboardLayoutSlice = createSlice({
|
||||
reducers: dashboardLayoutReducers,
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchLayout.pending, (state) => {
|
||||
.addCase(GET_DASHBOARD.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchLayout.fulfilled, (state, action) => {
|
||||
.addCase(GET_DASHBOARD.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.initialized = true;
|
||||
state.cards = action.payload.cards;
|
||||
state.viewCards = action.payload.viewCards;
|
||||
state.browserCards = action.payload.browserCards;
|
||||
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
|
||||
const layout = action.payload.layout ?? {};
|
||||
const browserCards = (layout.browser_cards ?? {}) as Record<string, any>;
|
||||
|
||||
for (const card of Object.values(browserCards)) {
|
||||
if (!card.tabs || card.tabs.length === 0) {
|
||||
const tabId = generateTabId();
|
||||
card.tabs = [{ id: tabId, url: card.url || 'https://www.google.com', title: '' }];
|
||||
card.activeTabId = tabId;
|
||||
}
|
||||
if (!card.url && card.tabs.length > 0) {
|
||||
const active = card.tabs.find((t: any) => t.id === card.activeTabId) || card.tabs[0];
|
||||
card.url = active.url;
|
||||
}
|
||||
}
|
||||
|
||||
state.cards = (layout.cards ?? {}) as Record<string, CardPosition>;
|
||||
state.viewCards = (layout.view_cards ?? {}) as Record<string, ViewCardPosition>;
|
||||
state.browserCards = browserCards as Record<string, BrowserCardPosition>;
|
||||
state.persistedExpandedSessionIds = (layout.expanded_session_ids ?? []) as string[];
|
||||
|
||||
let maxZ = 0;
|
||||
for (const c of Object.values(state.cards)) {
|
||||
@@ -36,7 +52,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
}
|
||||
state.nextZOrder = maxZ + 1;
|
||||
})
|
||||
.addCase(fetchLayout.rejected, (state) => {
|
||||
.addCase(GET_DASHBOARD.rejected, (state) => {
|
||||
state.loading = false;
|
||||
state.initialized = true;
|
||||
})
|
||||
@@ -69,4 +85,3 @@ export const {
|
||||
export default dashboardLayoutSlice.reducer;
|
||||
|
||||
export * from './dashboardLayoutTypes';
|
||||
export * from './dashboardLayoutThunks';
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { CardPosition, ViewCardPosition, BrowserCardPosition, LayoutPayload, SaveLayoutPayload } from './dashboardLayoutTypes';
|
||||
import { generateTabId } from './dashboardLayoutTypes';
|
||||
|
||||
const DASHBOARDS_API = `${API_BASE}/dashboards`;
|
||||
|
||||
export const fetchLayout = createAsyncThunk(
|
||||
'dashboardLayout/fetch',
|
||||
async (dashboardId: string) => {
|
||||
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
|
||||
const data = await res.json();
|
||||
const layout = data.layout ?? {};
|
||||
const browserCards = (layout.browser_cards ?? {}) as Record<string, any>;
|
||||
|
||||
for (const card of Object.values(browserCards)) {
|
||||
if (!card.tabs || card.tabs.length === 0) {
|
||||
const tabId = generateTabId();
|
||||
card.tabs = [{ id: tabId, url: card.url || 'https://www.google.com', title: '' }];
|
||||
card.activeTabId = tabId;
|
||||
}
|
||||
if (!card.url && card.tabs.length > 0) {
|
||||
const active = card.tabs.find((t: any) => t.id === card.activeTabId) || card.tabs[0];
|
||||
card.url = active.url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
|
||||
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
|
||||
browserCards: browserCards as Record<string, BrowserCardPosition>,
|
||||
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
|
||||
} satisfies LayoutPayload;
|
||||
},
|
||||
);
|
||||
|
||||
export const saveLayout = createAsyncThunk(
|
||||
'dashboardLayout/save',
|
||||
async (payload: SaveLayoutPayload) => {
|
||||
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
layout: {
|
||||
cards: payload.cards,
|
||||
view_cards: payload.viewCards,
|
||||
browser_cards: payload.browserCards,
|
||||
expanded_session_ids: payload.expandedSessionIds,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return payload;
|
||||
},
|
||||
);
|
||||
@@ -72,17 +72,6 @@ export const initialState: DashboardLayoutState = {
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
export interface LayoutPayload {
|
||||
cards: Record<string, CardPosition>;
|
||||
viewCards: Record<string, ViewCardPosition>;
|
||||
browserCards: Record<string, BrowserCardPosition>;
|
||||
expandedSessionIds: string[];
|
||||
}
|
||||
|
||||
export interface SaveLayoutPayload extends LayoutPayload {
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user