g3 console initial cut + error doesnt kill auto
This commit is contained in:
80
crates/g3-console/web/js/api.js
Normal file
80
crates/g3-console/web/js/api.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// API client for G3 Console
|
||||
const API_BASE = '/api';
|
||||
|
||||
const api = {
|
||||
// Get all instances
|
||||
async getInstances() {
|
||||
const response = await fetch(`${API_BASE}/instances`);
|
||||
if (!response.ok) throw new Error('Failed to fetch instances');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get single instance details
|
||||
async getInstance(id) {
|
||||
const response = await fetch(`${API_BASE}/instances/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch instance');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get instance logs
|
||||
async getInstanceLogs(id) {
|
||||
const response = await fetch(`${API_BASE}/instances/${id}/logs`);
|
||||
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Launch new instance
|
||||
async launchInstance(data) {
|
||||
const response = await fetch(`${API_BASE}/instances/launch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Try to extract error message from response
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || errorData.error || 'Failed to launch instance');
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to launch instance (${response.status})`);
|
||||
}
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Kill instance
|
||||
async killInstance(id) {
|
||||
const response = await fetch(`${API_BASE}/instances/${id}/kill`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to kill instance');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Restart instance
|
||||
async restartInstance(id) {
|
||||
const response = await fetch(`${API_BASE}/instances/${id}/restart`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to restart instance');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get console state
|
||||
async getState() {
|
||||
const response = await fetch(`${API_BASE}/state`);
|
||||
if (!response.ok) throw new Error('Failed to fetch state');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Save console state
|
||||
async saveState(state) {
|
||||
const response = await fetch(`${API_BASE}/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save state');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
313
crates/g3-console/web/js/app.js
Normal file
313
crates/g3-console/web/js/app.js
Normal file
@@ -0,0 +1,313 @@
|
||||
// Main application logic
|
||||
|
||||
// Global action handlers
|
||||
window.handleKill = async function(id) {
|
||||
if (!confirm('Are you sure you want to kill this instance?')) return;
|
||||
|
||||
// Find the button and show loading state
|
||||
const button = event.target;
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Terminating...';
|
||||
|
||||
try {
|
||||
await api.killInstance(id);
|
||||
|
||||
// Show success state
|
||||
button.innerHTML = '✓ Terminated';
|
||||
button.classList.remove('btn-danger');
|
||||
button.classList.add('btn-secondary');
|
||||
|
||||
// Refresh after a short delay
|
||||
setTimeout(() => {
|
||||
router.handleRoute(router.currentRoute);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
// Restore button state on error
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
alert('Failed to kill instance: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.handleRestart = async function(id) {
|
||||
// Find the button and show loading state
|
||||
const button = event.target;
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Restarting...';
|
||||
|
||||
try {
|
||||
await api.restartInstance(id);
|
||||
|
||||
// Show intermediate states
|
||||
button.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Starting...';
|
||||
|
||||
// Wait a bit then show success
|
||||
setTimeout(() => {
|
||||
button.innerHTML = '✓ Running';
|
||||
button.classList.remove('btn-primary');
|
||||
button.classList.add('btn-success');
|
||||
}, 1500);
|
||||
|
||||
// Refresh current view
|
||||
setTimeout(() => {
|
||||
router.handleRoute(router.currentRoute);
|
||||
}, 2500);
|
||||
} catch (error) {
|
||||
// Restore button state on error
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
alert('Failed to kill instance: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal management
|
||||
const modal = {
|
||||
element: null,
|
||||
|
||||
init() {
|
||||
this.element = document.getElementById('new-run-modal');
|
||||
|
||||
// Close button
|
||||
document.getElementById('modal-close').addEventListener('click', () => this.close());
|
||||
document.getElementById('cancel-launch').addEventListener('click', () => this.close());
|
||||
|
||||
// Close on overlay click
|
||||
this.element.querySelector('.modal-overlay').addEventListener('click', () => this.close());
|
||||
|
||||
// Form submission
|
||||
document.getElementById('launch-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleLaunch();
|
||||
});
|
||||
|
||||
// File browser buttons - use HTML5 file input
|
||||
document.getElementById('browse-workspace').addEventListener('click', () => {
|
||||
this.browseDirectory('workspace');
|
||||
});
|
||||
|
||||
document.getElementById('browse-binary').addEventListener('click', () => {
|
||||
this.browseFile('g3-binary-path');
|
||||
});
|
||||
|
||||
// Provider change updates model options
|
||||
document.getElementById('provider').addEventListener('change', (e) => {
|
||||
this.updateModelOptions(e.target.value);
|
||||
});
|
||||
},
|
||||
|
||||
browseDirectory(inputId) {
|
||||
// Create a hidden file input with directory picker
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.webkitdirectory = true;
|
||||
input.directory = true;
|
||||
input.multiple = false;
|
||||
|
||||
input.onchange = (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
// Get the directory path from the first file
|
||||
const path = files[0].webkitRelativePath.split('/')[0];
|
||||
// In browser context, we get relative path, so we need to construct full path
|
||||
// For now, just use the directory name and let user adjust
|
||||
document.getElementById(inputId).value = path;
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
|
||||
browseFile(inputId) {
|
||||
// Create a hidden file input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '*';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
// Get the file name
|
||||
// Note: For security reasons, browsers don't give us the full path
|
||||
// User will need to type the full path manually
|
||||
document.getElementById(inputId).value = files[0].name;
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
|
||||
open() {
|
||||
// Load saved state
|
||||
const form = document.getElementById('launch-form');
|
||||
if (state.lastWorkspace) {
|
||||
form.workspace.value = state.lastWorkspace;
|
||||
}
|
||||
if (state.g3BinaryPath) {
|
||||
form.g3_binary_path.value = state.g3BinaryPath;
|
||||
}
|
||||
form.provider.value = state.lastProvider;
|
||||
this.updateModelOptions(state.lastProvider);
|
||||
form.model.value = state.lastModel;
|
||||
|
||||
this.element.classList.remove('hidden');
|
||||
},
|
||||
|
||||
close() {
|
||||
this.element.classList.add('hidden');
|
||||
},
|
||||
|
||||
updateModelOptions(provider) {
|
||||
const modelSelect = document.getElementById('model');
|
||||
const models = {
|
||||
databricks: [
|
||||
{ value: 'databricks-claude-sonnet-4-5', label: 'databricks-claude-sonnet-4-5' },
|
||||
{ value: 'databricks-meta-llama-3-1-405b-instruct', label: 'databricks-meta-llama-3-1-405b-instruct' }
|
||||
],
|
||||
anthropic: [
|
||||
{ value: 'claude-3-5-sonnet-20241022', label: 'claude-3-5-sonnet-20241022' },
|
||||
{ value: 'claude-3-opus-20240229', label: 'claude-3-opus-20240229' }
|
||||
],
|
||||
local: [
|
||||
{ value: 'local-model', label: 'Local Model' }
|
||||
]
|
||||
};
|
||||
|
||||
modelSelect.innerHTML = '';
|
||||
for (const model of models[provider] || []) {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.value;
|
||||
option.textContent = model.label;
|
||||
modelSelect.appendChild(option);
|
||||
}
|
||||
},
|
||||
|
||||
async handleLaunch() {
|
||||
const form = document.getElementById('launch-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
prompt: formData.get('prompt'),
|
||||
workspace: formData.get('workspace'),
|
||||
provider: formData.get('provider'),
|
||||
model: formData.get('model'),
|
||||
mode: formData.get('mode'),
|
||||
g3_binary_path: formData.get('g3_binary_path') || null
|
||||
};
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const modalBody = this.element.querySelector('.modal-body');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Starting g3 instance...';
|
||||
|
||||
const response = await api.launchInstance(data);
|
||||
|
||||
// Show intermediate state
|
||||
submitBtn.innerHTML = '<span class="spinner" style="width: 1rem; height: 1rem; border-width: 2px; display: inline-block; vertical-align: middle;"></span> Waiting for process...';
|
||||
|
||||
// Wait a bit to let the process start
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
submitBtn.innerHTML = '✓ Instance started!';
|
||||
|
||||
// Save state
|
||||
state.updateLaunchDefaults(
|
||||
data.workspace,
|
||||
data.provider,
|
||||
data.model,
|
||||
data.g3_binary_path
|
||||
);
|
||||
|
||||
// Close modal and navigate home
|
||||
this.close();
|
||||
router.navigate('/');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Start Instance';
|
||||
} catch (error) {
|
||||
// Display detailed error message in modal
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error-message';
|
||||
errorDiv.style.cssText = 'background: #fee; border: 1px solid #fcc; color: #c33; padding: 1rem; margin: 1rem 0; border-radius: 0.5rem;';
|
||||
|
||||
let errorMessage = 'Failed to launch instance';
|
||||
if (error.message) {
|
||||
errorMessage += ': ' + error.message;
|
||||
}
|
||||
|
||||
// Check for specific error types
|
||||
if (error.message && error.message.includes('400')) {
|
||||
errorMessage = 'Invalid configuration. Please check that the g3 binary path exists and is executable, and that the workspace directory is valid.';
|
||||
} else if (error.message && error.message.includes('500')) {
|
||||
errorMessage = 'Server error while launching instance. Check console logs for details.';
|
||||
}
|
||||
|
||||
errorDiv.textContent = errorMessage;
|
||||
|
||||
// Remove any existing error messages
|
||||
const existingError = modalBody.querySelector('.error-message');
|
||||
if (existingError) existingError.remove();
|
||||
|
||||
// Insert error message at the top of modal body
|
||||
modalBody.insertBefore(errorDiv, modalBody.firstChild);
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Start Instance';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Theme toggle
|
||||
function initTheme() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const newTheme = state.theme === 'dark' ? 'light' : 'dark';
|
||||
state.setTheme(newTheme);
|
||||
themeToggle.textContent = newTheme === 'dark' ? '🌙' : '☀️';
|
||||
});
|
||||
|
||||
// Set initial theme
|
||||
document.body.className = state.theme;
|
||||
themeToggle.textContent = state.theme === 'dark' ? '🌙' : '☀️';
|
||||
}
|
||||
|
||||
// Initialize app
|
||||
async function init() {
|
||||
// Prevent double initialization
|
||||
if (window.g3Initialized) {
|
||||
return;
|
||||
}
|
||||
window.g3Initialized = true;
|
||||
|
||||
// Load state
|
||||
await state.load();
|
||||
|
||||
// Initialize theme
|
||||
initTheme();
|
||||
|
||||
// Initialize modal
|
||||
modal.init();
|
||||
|
||||
// New Run button
|
||||
document.getElementById('new-run-btn').addEventListener('click', () => {
|
||||
modal.open();
|
||||
});
|
||||
|
||||
// Initialize router
|
||||
router.init();
|
||||
}
|
||||
|
||||
// Simplified initialization - call exactly once when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
// DOM still loading, wait for DOMContentLoaded
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
// DOM already loaded (interactive or complete), init immediately
|
||||
init();
|
||||
}
|
||||
311
crates/g3-console/web/js/components.js
Normal file
311
crates/g3-console/web/js/components.js
Normal file
@@ -0,0 +1,311 @@
|
||||
// UI Components for G3 Console
|
||||
|
||||
const components = {
|
||||
// Render status badge
|
||||
statusBadge(status) {
|
||||
const colors = {
|
||||
running: 'badge-success',
|
||||
completed: 'badge-success',
|
||||
failed: 'badge-error',
|
||||
idle: 'badge-warning',
|
||||
terminated: 'badge-neutral'
|
||||
};
|
||||
return `<span class="badge ${colors[status] || 'badge-neutral'}">${status}</span>`;
|
||||
},
|
||||
|
||||
// Render progress bar
|
||||
progressBar(instance, stats) {
|
||||
const duration = stats.duration_secs;
|
||||
const estimated = duration * 1.5; // Simple estimation
|
||||
const progress = Math.min((duration / estimated) * 100, 100);
|
||||
|
||||
// Check if this is ensemble mode with turn data
|
||||
if (instance.instance_type === 'ensemble' && stats.turns && stats.turns.length > 0) {
|
||||
return this.ensembleProgressBar(stats.turns, duration);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||||
<span class="progress-text">${Math.round(duration / 60)}m elapsed</span>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render multi-segment progress bar for ensemble mode
|
||||
ensembleProgressBar(turns, totalDuration) {
|
||||
const colors = {
|
||||
coach: '#3b82f6',
|
||||
player: '#6b7280',
|
||||
completed: '#10b981',
|
||||
error: '#ef4444'
|
||||
};
|
||||
|
||||
let segments = '';
|
||||
for (const turn of turns) {
|
||||
const percentage = (turn.duration_secs / totalDuration) * 100;
|
||||
const color = colors[turn.agent] || colors.player;
|
||||
const statusColor = turn.status === 'error' ? colors.error : color;
|
||||
|
||||
segments += `
|
||||
<div class="progress-segment"
|
||||
style="width: ${percentage}%; background-color: ${statusColor};"
|
||||
title="${turn.agent}: ${turn.duration_secs}s - ${turn.status}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="progress-bar ensemble">
|
||||
${segments}
|
||||
<span class="progress-text">${Math.round(totalDuration / 60)}m elapsed</span>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render instance panel
|
||||
instancePanel(instance, stats, latestMessage) {
|
||||
return `
|
||||
<div class="instance-panel" data-id="${instance.id}" onclick="router.navigate('/instance/${instance.id}')">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">
|
||||
<h3>${instance.workspace}</h3>
|
||||
${this.statusBadge(instance.status)}
|
||||
</div>
|
||||
<div class="panel-meta">
|
||||
<span class="meta-item">${instance.instance_type}</span>
|
||||
<span class="meta-item">PID: ${instance.pid}</span>
|
||||
<span class="meta-item">${new Date(instance.start_time).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.progressBar(instance, stats)}
|
||||
|
||||
<div class="panel-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Tokens</span>
|
||||
<span class="stat-value">${stats.total_tokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Tool Calls</span>
|
||||
<span class="stat-value">${stats.tool_calls}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Errors</span>
|
||||
<span class="stat-value">${stats.errors}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Duration</span>
|
||||
<span class="stat-value">${Math.round(stats.duration_secs / 60)}m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${latestMessage ? `
|
||||
<div class="panel-message">
|
||||
<strong>Latest:</strong> ${this.truncate(latestMessage, 100)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="panel-actions">
|
||||
${instance.status === 'running' ? `
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); handleKill('${instance.id}')">Kill</button>
|
||||
` : ''}
|
||||
${instance.status === 'terminated' ? `
|
||||
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); handleRestart('${instance.id}')">Restart</button>
|
||||
` : ''}
|
||||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); router.navigate('/instance/${instance.id}')">View Details</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render loading spinner
|
||||
spinner(message = 'Loading...') {
|
||||
return `
|
||||
<div class="spinner-container">
|
||||
<div class="spinner"></div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render error message
|
||||
error(message) {
|
||||
return `
|
||||
<div class="error-message">
|
||||
<strong>Error:</strong> ${message}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render empty state
|
||||
emptyState(message) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Truncate text
|
||||
truncate(text, length) {
|
||||
if (text.length <= length) return text;
|
||||
return text.substring(0, length) + '...';
|
||||
},
|
||||
|
||||
// Render chat message
|
||||
chatMessage(message, agent = null) {
|
||||
const agentClass = agent === 'coach' ? 'message-coach' : agent === 'player' ? 'message-player' : '';
|
||||
return `
|
||||
<div class="chat-message ${agentClass}">
|
||||
${agent ? `<div class="message-agent">${agent}</div>` : ''}
|
||||
<div class="message-content">${marked.parse(message)}</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render tool call
|
||||
toolCall(toolCall) {
|
||||
const statusIcon = toolCall.success ? '✓' : '✗';
|
||||
const statusClass = toolCall.success ? 'success' : 'error';
|
||||
|
||||
return `
|
||||
<div class="tool-call" data-tool-id="${toolCall.id}">
|
||||
<div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||
<span class="tool-name">🔧 ${toolCall.tool_name}</span>
|
||||
<div class="tool-header-right">
|
||||
${toolCall.execution_time_ms ? `<span class="tool-time">${toolCall.execution_time_ms}ms</span>` : ''}
|
||||
<span class="tool-status ${statusClass}">${statusIcon}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-details">
|
||||
<div class="tool-section">
|
||||
<strong>Parameters:</strong>
|
||||
<pre><code class="language-json">${JSON.stringify(toolCall.parameters, null, 2)}</code></pre>
|
||||
</div>
|
||||
${toolCall.result ? `
|
||||
<div class="tool-section">
|
||||
<strong>Result:</strong>
|
||||
<pre><code class="language-json">${JSON.stringify(toolCall.result, null, 2)}</code></pre>
|
||||
</div>
|
||||
` : ''}
|
||||
${toolCall.error ? `
|
||||
<div class="tool-section">
|
||||
<strong>Error:</strong>
|
||||
<pre><code class="language-text">${this.escapeHtml(toolCall.error)}</code></pre>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="tool-meta">
|
||||
<span>Timestamp: ${new Date(toolCall.timestamp).toLocaleString()}</span>
|
||||
${toolCall.execution_time_ms ? `<span> • Duration: ${toolCall.execution_time_ms}ms</span>` : ''}
|
||||
<span> • Status: ${toolCall.success ? 'Success' : 'Failed'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render git status section
|
||||
gitStatus(gitStatus) {
|
||||
if (!gitStatus) {
|
||||
return '<p class="text-muted">No git repository detected</p>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="git-status">
|
||||
<div class="git-header">
|
||||
<span class="git-branch">📍 ${gitStatus.branch}</span>
|
||||
<span class="git-changes">${gitStatus.uncommitted_changes} uncommitted changes</span>
|
||||
</div>
|
||||
${gitStatus.uncommitted_changes > 0 ? `
|
||||
<div class="git-files">
|
||||
${gitStatus.modified_files.length > 0 ? `
|
||||
<div class="git-file-group">
|
||||
<strong class="file-status modified">Modified:</strong>
|
||||
<ul>
|
||||
${gitStatus.modified_files.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${gitStatus.added_files.length > 0 ? `
|
||||
<div class="git-file-group">
|
||||
<strong class="file-status added">Added:</strong>
|
||||
<ul>
|
||||
${gitStatus.added_files.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${gitStatus.deleted_files.length > 0 ? `
|
||||
<div class="git-file-group">
|
||||
<strong class="file-status deleted">Deleted:</strong>
|
||||
<ul>
|
||||
${gitStatus.deleted_files.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Render project files section
|
||||
projectFiles(projectFiles) {
|
||||
if (!projectFiles || (!projectFiles.requirements && !projectFiles.readme && !projectFiles.agents)) {
|
||||
return '<p class="text-muted">No project files found</p>';
|
||||
}
|
||||
|
||||
let html = '<div class="project-files">';
|
||||
|
||||
if (projectFiles.requirements) {
|
||||
html += `
|
||||
<div class="project-file">
|
||||
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||
<span class="file-name">📄 requirements.md</span>
|
||||
<span class="file-toggle">▼</span>
|
||||
</div>
|
||||
<div class="file-content">
|
||||
<pre><code>${this.escapeHtml(projectFiles.requirements)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (projectFiles.readme) {
|
||||
html += `
|
||||
<div class="project-file">
|
||||
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||
<span class="file-name">📄 README.md</span>
|
||||
<span class="file-toggle">▼</span>
|
||||
</div>
|
||||
<div class="file-content">
|
||||
<pre><code>${this.escapeHtml(projectFiles.readme)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (projectFiles.agents) {
|
||||
html += `
|
||||
<div class="project-file">
|
||||
<div class="file-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||||
<span class="file-name">📄 AGENTS.md</span>
|
||||
<span class="file-toggle">▼</span>
|
||||
</div>
|
||||
<div class="file-content">
|
||||
<pre><code>${this.escapeHtml(projectFiles.agents)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
1213
crates/g3-console/web/js/highlight.min.js
vendored
Normal file
1213
crates/g3-console/web/js/highlight.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
crates/g3-console/web/js/marked.min.js
vendored
Normal file
6
crates/g3-console/web/js/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
167
crates/g3-console/web/js/router.js
Normal file
167
crates/g3-console/web/js/router.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// Simple client-side router
|
||||
const router = {
|
||||
currentRoute: '/',
|
||||
|
||||
init() {
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', () => {
|
||||
this.handleRoute(window.location.pathname);
|
||||
});
|
||||
|
||||
// Handle initial route
|
||||
this.handleRoute(window.location.pathname);
|
||||
},
|
||||
|
||||
navigate(path) {
|
||||
window.history.pushState({}, '', path);
|
||||
this.handleRoute(path);
|
||||
},
|
||||
|
||||
async handleRoute(path) {
|
||||
this.currentRoute = path;
|
||||
const container = document.getElementById('page-container');
|
||||
|
||||
if (path === '/' || path === '') {
|
||||
await this.renderHome(container);
|
||||
} else if (path.startsWith('/instance/')) {
|
||||
const id = path.split('/')[2];
|
||||
await this.renderDetail(container, id);
|
||||
} else {
|
||||
container.innerHTML = components.error('Page not found');
|
||||
}
|
||||
},
|
||||
|
||||
async renderHome(container) {
|
||||
container.innerHTML = components.spinner('Loading instances...');
|
||||
|
||||
try {
|
||||
const instances = await api.getInstances();
|
||||
|
||||
if (instances.length === 0) {
|
||||
container.innerHTML = components.emptyState(
|
||||
'No running instances. Click "+ New Run" to start one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="instances-list">';
|
||||
for (const instance of instances) {
|
||||
// Use stats from API response
|
||||
const stats = instance.stats || { total_tokens: 0, tool_calls: 0, errors: 0, duration_secs: 0 };
|
||||
html += components.instancePanel(instance, stats, instance.latest_message);
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Auto-refresh every 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.currentRoute === '/') {
|
||||
this.renderHome(container);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
container.innerHTML = components.error(error.message);
|
||||
}
|
||||
},
|
||||
|
||||
async renderDetail(container, id) {
|
||||
container.innerHTML = components.spinner('Loading instance details...');
|
||||
|
||||
try {
|
||||
const instance = await api.getInstance(id);
|
||||
const logs = await api.getInstanceLogs(id);
|
||||
|
||||
// Build detail view HTML
|
||||
let html = `
|
||||
<div class="detail-view">
|
||||
<div class="detail-header">
|
||||
<button class="btn btn-secondary" onclick="router.navigate('/')">← Back</button>
|
||||
<h2>${instance.workspace}</h2>
|
||||
${components.statusBadge(instance.status)}
|
||||
</div>
|
||||
|
||||
<div class="detail-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Tokens</div>
|
||||
<div class="stat-value">${(instance.stats?.total_tokens || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Tool Calls</div>
|
||||
<div class="stat-value">${instance.stats?.tool_calls || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Errors</div>
|
||||
<div class="stat-value">${instance.stats?.errors || 0}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Duration</div>
|
||||
<div class="stat-value">${Math.round((instance.stats?.duration_secs || 0) / 60)}m</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Git Status</h3>
|
||||
${components.gitStatus(instance.git_status)}
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Project Files</h3>
|
||||
${components.projectFiles(instance.project_files)}
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<h3>Tool Calls</h3>
|
||||
<div class="tool-calls-section">
|
||||
`;
|
||||
|
||||
// Render tool calls
|
||||
if (logs && logs.tool_calls && logs.tool_calls.length > 0) {
|
||||
for (const toolCall of logs.tool_calls) {
|
||||
html += components.toolCall(toolCall);
|
||||
}
|
||||
} else {
|
||||
html += '<p class="text-muted">No tool calls yet</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
|
||||
<h3>Chat History</h3>
|
||||
<div class="chat-messages">
|
||||
`;
|
||||
|
||||
// Render messages from logs
|
||||
if (logs && logs.messages && logs.messages.length > 0) {
|
||||
for (const msg of logs.messages) {
|
||||
html += components.chatMessage(msg.content, msg.agent);
|
||||
}
|
||||
} else {
|
||||
html += '<p class="text-muted">No messages yet</p>';
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Apply syntax highlighting
|
||||
document.querySelectorAll('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
|
||||
// Auto-refresh every 3 seconds
|
||||
setTimeout(() => {
|
||||
if (this.currentRoute === `/instance/${id}`) {
|
||||
this.renderDetail(container, id);
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
container.innerHTML = components.error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
51
crates/g3-console/web/js/state.js
Normal file
51
crates/g3-console/web/js/state.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// State management for G3 Console
|
||||
const state = {
|
||||
theme: 'dark',
|
||||
lastWorkspace: null,
|
||||
g3BinaryPath: null,
|
||||
lastProvider: 'databricks',
|
||||
lastModel: 'databricks-claude-sonnet-4-5',
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const data = await api.getState();
|
||||
this.theme = data.theme || 'dark';
|
||||
this.lastWorkspace = data.last_workspace;
|
||||
this.g3BinaryPath = data.g3_binary_path;
|
||||
this.lastProvider = data.last_provider || 'databricks';
|
||||
this.lastModel = data.last_model || 'databricks-claude-sonnet-4-5';
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to load state:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async save() {
|
||||
try {
|
||||
await api.saveState({
|
||||
theme: this.theme,
|
||||
last_workspace: this.lastWorkspace,
|
||||
g3_binary_path: this.g3BinaryPath,
|
||||
last_provider: this.lastProvider,
|
||||
last_model: this.lastModel
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save state:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
document.body.className = theme;
|
||||
this.save();
|
||||
},
|
||||
|
||||
updateLaunchDefaults(workspace, provider, model, binaryPath) {
|
||||
this.lastWorkspace = workspace;
|
||||
this.lastProvider = provider;
|
||||
this.lastModel = model;
|
||||
if (binaryPath) this.g3BinaryPath = binaryPath;
|
||||
this.save();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user