feat(opax-ui): make Emma a modern operator workspace
This commit is contained in:
parent
8f1c501651
commit
c646649a76
|
|
@ -101,4 +101,13 @@ router.get('/capabilities', (req, res) => {
|
||||||
res.json(capabilities);
|
res.json(capabilities);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/emma/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await callAgent('get_health', {});
|
||||||
|
res.json({ status: 'ready', label: 'Emma Local', message: 'Emma is ready.' });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ status: 'unavailable', label: 'Emma Local', message: 'Emma is unavailable.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
const AgentConsoleView = () => {
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [transcript, setTranscript] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [ticketNumber, setTicketNumber] = useState(null);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!message.trim()) return;
|
|
||||||
|
|
||||||
const newMessage = { role: 'user', content: message };
|
|
||||||
setTranscript(prev => [...prev, newMessage]);
|
|
||||||
setMessage('');
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/console/emma/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ message, ticket_number: ticketNumber }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Emma is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const emmaMessage = { role: 'assistant', content: data.reply };
|
|
||||||
setTranscript(prev => [...prev, emmaMessage]);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="console-card">
|
|
||||||
<h2>Emma Local</h2>
|
|
||||||
<div className="agent-info">
|
|
||||||
<span>Local model · server-side memory enabled</span>
|
|
||||||
{ticketNumber && <span>Working context: Gitea #{ticketNumber}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="transcript">
|
|
||||||
{transcript.map((msg, index) => (
|
|
||||||
<div key={index} className={`message ${msg.role}`}>
|
|
||||||
<strong>{msg.role}: </strong>{msg.content}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{loading && <div className="message assistant">...</div>}
|
|
||||||
{error && <div className="message error">{error}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="chat-input">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
|
|
||||||
placeholder="Chat with Emma..."
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<button onClick={handleSend} disabled={loading}>
|
|
||||||
{loading ? 'Sending...' : 'Send'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AgentConsoleView;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const ApprovalsView = () => {
|
|
||||||
return (
|
|
||||||
<div className="console-card">
|
|
||||||
<h2>Approvals</h2>
|
|
||||||
<p className="unavailable-message">No approval read model configured yet.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ApprovalsView;
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Capability {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Capabilities {
|
||||||
|
[key: string]: Capability;
|
||||||
|
}
|
||||||
|
|
||||||
const CapabilityPanel = () => {
|
const CapabilityPanel = () => {
|
||||||
const [capabilities, setCapabilities] = useState({});
|
const [capabilities, setCapabilities] = useState<Capabilities>({});
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCapabilities = async () => {
|
const fetchCapabilities = async () => {
|
||||||
|
|
@ -13,35 +22,36 @@ const CapabilityPanel = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const capabilityGroups = {
|
const capabilityGroups = {
|
||||||
'Platform Status': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'],
|
'Platform': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'],
|
||||||
'Git and Gitea': ['list_commits', 'get_file'],
|
'Repository': ['list_commits', 'get_file'],
|
||||||
'Tickets and Work Queue': [],
|
'Work and tickets': [],
|
||||||
'Code Agents': ['run_emma'],
|
'Code agents': ['run_emma'],
|
||||||
'Tests and Validation': [],
|
'Validation': [],
|
||||||
'Builds and Deployments': ['trigger_build', 'commit_and_push_files'],
|
'Deployment': ['trigger_build', 'commit_and_push_files'],
|
||||||
'Cloud Run and Infrastructure': [],
|
'Costs': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'],
|
||||||
'Logs and Telemetry': [],
|
|
||||||
'Billing and CostGuard': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'],
|
|
||||||
'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'],
|
|
||||||
'Notifications': ['send_email', 'send_sms', 'send_webhook'],
|
'Notifications': ['send_email', 'send_sms', 'send_webhook'],
|
||||||
|
'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'],
|
||||||
'Memory and Knowledge': [],
|
'Memory and Knowledge': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="capability-panel">
|
<div className={`capability-drawer ${isOpen ? 'open' : ''}`}>
|
||||||
<h3>Capabilities</h3>
|
<button onClick={() => setIsOpen(!isOpen)} className="drawer-toggle">Capabilities</button>
|
||||||
{Object.entries(capabilityGroups).map(([group, capabilityKeys]) => (
|
<div className="drawer-content">
|
||||||
<div key={group} className="capability-group">
|
<h3>Capabilities</h3>
|
||||||
<h4>{group}</h4>
|
{Object.entries(capabilityGroups).map(([group, capabilityKeys]) => (
|
||||||
<ul>
|
<div key={group} className="capability-group">
|
||||||
{capabilityKeys.map(key => {
|
<h4>{group}</h4>
|
||||||
const capability = capabilities[key];
|
<ul>
|
||||||
if (!capability) return null;
|
{capabilityKeys.map(key => {
|
||||||
return <li key={key} className={capability.type}>{key}</li>
|
const capability = capabilities[key];
|
||||||
})}
|
if (!capability) return null;
|
||||||
</ul>
|
return <li key={key} className={capability.type}>{key.replace(/_/g, ' ')}</li>
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
105
opax-web/frontend/src/components/ChatWorkspace.tsx
Normal file
105
opax-web/frontend/src/components/ChatWorkspace.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatWorkspace = () => {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [transcript, setTranscript] = useState<Message[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [emmaStatus, setEmmaStatus] = useState<string>('unavailable');
|
||||||
|
const [ticketNumber, setTicketNumber] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchEmmaStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/console/emma/status', { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
setEmmaStatus(data.status);
|
||||||
|
} catch (e) {
|
||||||
|
setEmmaStatus('unavailable');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchEmmaStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
|
||||||
|
const newMessage: Message = { role: 'user', content: message };
|
||||||
|
setTranscript(prev => [...prev, newMessage]);
|
||||||
|
setMessage('');
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/console/emma/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ message, ticket_number: ticketNumber }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Emma is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const emmaMessage: Message = { role: 'assistant', content: data.reply };
|
||||||
|
setTranscript(prev => [...prev, emmaMessage]);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-workspace">
|
||||||
|
<div className="chat-header">
|
||||||
|
<h3>Emma Local</h3>
|
||||||
|
<span>{emmaStatus === 'ready' ? 'Connected' : 'Unavailable'}</span>
|
||||||
|
{ticketNumber && <span className="ticket-chip">Gitea #{ticketNumber}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="transcript">
|
||||||
|
{transcript.length === 0 && !loading && (
|
||||||
|
<div className="welcome-message">
|
||||||
|
<p>Hei Chris. Hva vil du at jeg skal få gjort?</p>
|
||||||
|
<div className="quick-prompts">
|
||||||
|
<button>Status på plattformen</button>
|
||||||
|
<button>Hva er neste oppgave?</button>
|
||||||
|
<button>Arbeid med Gitea #1</button>
|
||||||
|
<button>Sjekk siste build</button>
|
||||||
|
<button>Vis kostnadsavvik</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{transcript.map((msg, index) => (
|
||||||
|
<div key={index} className={`message ${msg.role}`}>
|
||||||
|
<strong>{msg.role}: </strong>{msg.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && <div className="message assistant">...</div>}
|
||||||
|
{error && <div className="message error">{error} <button onClick={handleSend}>Retry</button></div>}
|
||||||
|
</div>
|
||||||
|
<div className="chat-composer">
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||||
|
placeholder="Chat with Emma..."
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSend} disabled={loading}>
|
||||||
|
{loading ? 'Sending...' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatWorkspace;
|
||||||
28
opax-web/frontend/src/components/ContextPanel.tsx
Normal file
28
opax-web/frontend/src/components/ContextPanel.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React from 'react';
|
||||||
|
import CapabilityPanel from './CapabilityPanel';
|
||||||
|
|
||||||
|
const ContextPanel = () => {
|
||||||
|
return (
|
||||||
|
<div className="context-panel">
|
||||||
|
<div className="current-task">
|
||||||
|
<h4>Current task</h4>
|
||||||
|
<div>Idle</div>
|
||||||
|
</div>
|
||||||
|
<div className="ticket-context">
|
||||||
|
<h4>Ticket context</h4>
|
||||||
|
<div>No active ticket</div>
|
||||||
|
</div>
|
||||||
|
<div className="suggested-actions">
|
||||||
|
<h4>Suggested actions</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Check platform</li>
|
||||||
|
<li>Review Gitea #1</li>
|
||||||
|
<li>Check last deployment</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<CapabilityPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContextPanel;
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
type ConsoleStatus = 'success' | 'unavailable' | 'loading';
|
|
||||||
|
|
||||||
type SafeHealthSummary = {
|
|
||||||
status: 'ok';
|
|
||||||
service: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConsoleStatusItem = {
|
|
||||||
status: Exclude<ConsoleStatus, 'loading'>;
|
|
||||||
summary: SafeHealthSummary | null;
|
|
||||||
message: 'Available' | 'Unavailable';
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConsoleStatusResponse {
|
|
||||||
health?: ConsoleStatusItem;
|
|
||||||
buildStatus?: ConsoleStatusItem;
|
|
||||||
platformState?: ConsoleStatusItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusBlockProps {
|
|
||||||
title: string;
|
|
||||||
status: ConsoleStatus;
|
|
||||||
summary: SafeHealthSummary | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusBlock: React.FC<StatusBlockProps> = ({ title, status, summary }) => {
|
|
||||||
return (
|
|
||||||
<div className={`status-block status-${status}`}>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
{status === 'loading' && <p>Loading...</p>}
|
|
||||||
{status === 'unavailable' && <p>Unavailable</p>}
|
|
||||||
{status === 'success' && summary && (
|
|
||||||
<ul>
|
|
||||||
<li><strong>Status:</strong> {summary.status}</li>
|
|
||||||
<li><strong>Service:</strong> {summary.service}</li>
|
|
||||||
<li><strong>Version:</strong> {summary.version}</li>
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PlatformStatusProps {
|
|
||||||
statusData: ConsoleStatusResponse | null;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unavailableStatus: ConsoleStatus = 'unavailable';
|
|
||||||
|
|
||||||
const PlatformStatus: React.FC<PlatformStatusProps> = ({ statusData, loading }) => {
|
|
||||||
return (
|
|
||||||
<div className="console-card">
|
|
||||||
<h2>Platform Overview</h2>
|
|
||||||
<div className="status-container">
|
|
||||||
<StatusBlock
|
|
||||||
title="Health"
|
|
||||||
status={loading ? 'loading' : statusData?.health?.status ?? unavailableStatus}
|
|
||||||
summary={statusData?.health?.summary ?? null}
|
|
||||||
/>
|
|
||||||
<StatusBlock
|
|
||||||
title="Latest Build"
|
|
||||||
status={loading ? 'loading' : statusData?.buildStatus?.status ?? unavailableStatus}
|
|
||||||
summary={null} // Schema not confirmed, so always null for now
|
|
||||||
/>
|
|
||||||
<StatusBlock
|
|
||||||
title="Platform State"
|
|
||||||
status={loading ? 'loading' : statusData?.platformState?.status ?? unavailableStatus}
|
|
||||||
summary={null} // Schema not confirmed, so always null for now
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlatformStatus;
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const ProjectRegistryView = () => {
|
|
||||||
return (
|
|
||||||
<div className="console-card">
|
|
||||||
<h2>Projects</h2>
|
|
||||||
<p className="unavailable-message">No project registry configured yet.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProjectRegistryView;
|
|
||||||
26
opax-web/frontend/src/components/Sidebar.tsx
Normal file
26
opax-web/frontend/src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Sidebar = () => {
|
||||||
|
return (
|
||||||
|
<div className="sidebar">
|
||||||
|
<div className="logo">OPAX</div>
|
||||||
|
<div className="emma-status">Emma Local: Connected</div>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li className="active">Chat</li>
|
||||||
|
<li>Work</li>
|
||||||
|
<li>Repository</li>
|
||||||
|
<li>Deployments</li>
|
||||||
|
<li>Costs</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="ticket-context">
|
||||||
|
<div>Current selected ticket:</div>
|
||||||
|
<div>No active task</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile">User Profile</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const WorkQueueView = () => {
|
|
||||||
return (
|
|
||||||
<div className="console-card">
|
|
||||||
<h2>Work Queue</h2>
|
|
||||||
<p className="unavailable-message">No ticket read model configured yet.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkQueueView;
|
|
||||||
|
|
@ -1,138 +1,14 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React from 'react';
|
||||||
import PlatformStatus from '../components/PlatformStatus';
|
import Sidebar from '../components/Sidebar';
|
||||||
import ProjectRegistryView from '../components/ProjectRegistryView';
|
import ChatWorkspace from '../components/ChatWorkspace';
|
||||||
import WorkQueueView from '../components/WorkQueueView';
|
import ContextPanel from '../components/ContextPanel';
|
||||||
import ApprovalsView from '../components/ApprovalsView';
|
|
||||||
import AgentConsoleView from '../components/AgentConsoleView';
|
|
||||||
import CapabilityPanel from '../components/CapabilityPanel';
|
|
||||||
|
|
||||||
// --- Types for Safe Data Handling ---
|
|
||||||
type ConsoleStatus = 'success' | 'unavailable';
|
|
||||||
|
|
||||||
type SafeHealthSummary = {
|
|
||||||
status: 'ok';
|
|
||||||
service: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConsoleStatusItem = {
|
|
||||||
status: ConsoleStatus;
|
|
||||||
summary: SafeHealthSummary | null;
|
|
||||||
message: 'Available' | 'Unavailable';
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ConsoleStatusResponse {
|
|
||||||
health: ConsoleStatusItem;
|
|
||||||
buildStatus: ConsoleStatusItem;
|
|
||||||
platformState: ConsoleStatusItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unavailableItem: ConsoleStatusItem = {
|
|
||||||
status: 'unavailable',
|
|
||||||
summary: null,
|
|
||||||
message: 'Unavailable',
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Normalizer Function ---
|
|
||||||
type UnknownRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is UnknownRecord {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHealthSummary(value: unknown): value is SafeHealthSummary {
|
|
||||||
return (
|
|
||||||
isRecord(value) &&
|
|
||||||
value.status === 'ok' &&
|
|
||||||
typeof value.service === 'string' &&
|
|
||||||
typeof value.version === 'string'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStatusResponse(raw: unknown): ConsoleStatusResponse {
|
|
||||||
let health: ConsoleStatusItem = unavailableItem;
|
|
||||||
|
|
||||||
if (isRecord(raw) && isRecord(raw.health)) {
|
|
||||||
const healthItem = raw.health;
|
|
||||||
|
|
||||||
if (
|
|
||||||
healthItem.status === 'success' &&
|
|
||||||
isHealthSummary(healthItem.summary)
|
|
||||||
) {
|
|
||||||
health = {
|
|
||||||
status: 'success',
|
|
||||||
summary: {
|
|
||||||
status: 'ok',
|
|
||||||
service: healthItem.summary.service,
|
|
||||||
version: healthItem.summary.version,
|
|
||||||
},
|
|
||||||
message: 'Available',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
health,
|
|
||||||
buildStatus: unavailableItem,
|
|
||||||
platformState: unavailableItem,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Component ---
|
|
||||||
const OperationsConsole = () => {
|
const OperationsConsole = () => {
|
||||||
const [statusData, setStatusData] = useState<ConsoleStatusResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [lastRefreshed, setLastRefreshed] = useState<string>('');
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/console/status', { credentials: 'include' });
|
|
||||||
if (!res.ok) {
|
|
||||||
// Handle auth errors or server errors safely
|
|
||||||
if (res.status === 401) {
|
|
||||||
throw new Error('Access denied. Please log in again.');
|
|
||||||
} else {
|
|
||||||
throw new Error('Console data is unavailable.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const rawData = await res.json();
|
|
||||||
setStatusData(normalizeStatusResponse(rawData));
|
|
||||||
setLastRefreshed(new Date().toLocaleTimeString());
|
|
||||||
} catch {
|
|
||||||
setError('Console data is unavailable. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="console-page">
|
<div className="console-layout">
|
||||||
<div className="page-header">
|
<Sidebar />
|
||||||
<h1>OPAX Operations Console</h1>
|
<ChatWorkspace />
|
||||||
<div className="header-actions">
|
<ContextPanel />
|
||||||
<span>Last refreshed: {lastRefreshed || 'never'}</span>
|
|
||||||
<button onClick={fetchData} disabled={loading} className="primary">
|
|
||||||
{loading ? 'Refreshing...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
|
||||||
|
|
||||||
<PlatformStatus statusData={statusData} loading={loading} />
|
|
||||||
<ProjectRegistryView />
|
|
||||||
<WorkQueueView />
|
|
||||||
<ApprovalsView />
|
|
||||||
<AgentConsoleView />
|
|
||||||
<CapabilityPanel />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user