use super::explain::ExplainOutput;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebUIConfig {
pub theme: Theme,
pub layout: Layout,
pub enable_animations: bool,
pub enable_tooltips: bool,
pub font_size: FontSize,
pub accessibility_mode: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum Theme {
Light,
Dark,
Auto,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum Layout {
Tree,
Flow,
Compact,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum FontSize {
Small,
Medium,
Large,
}
impl Default for WebUIConfig {
fn default() -> Self {
Self {
theme: Theme::Auto,
layout: Layout::Tree,
enable_animations: true,
enable_tooltips: true,
font_size: FontSize::Medium,
accessibility_mode: false,
}
}
}
pub struct WebUIRenderer {
config: WebUIConfig,
}
impl WebUIRenderer {
pub fn new(config: WebUIConfig) -> Self {
Self { config }
}
pub fn render_html(&self, output: &ExplainOutput) -> String {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n");
html.push_str("<html lang=\"en\">\n");
html.push_str("<head>\n");
html.push_str(" <meta charset=\"UTF-8\">\n");
html.push_str(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
html.push_str(" <title>HeliosDB EXPLAIN Analysis</title>\n");
html.push_str(&self.generate_css());
html.push_str("</head>\n");
html.push_str("<body");
if matches!(self.config.theme, Theme::Dark) {
html.push_str(" class=\"dark-mode\"");
}
html.push_str(">\n");
html.push_str(&self.generate_header(output));
html.push_str(&self.generate_controls());
html.push_str(&self.generate_plan_visualization(output));
html.push_str(&self.generate_metrics(output));
html.push_str(&self.generate_features(output));
html.push_str(&self.generate_javascript());
html.push_str("</body>\n");
html.push_str("</html>\n");
html
}
fn generate_css(&self) -> String {
r#"
<style>
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #212121;
--text-secondary: #757575;
--border-color: #e0e0e0;
--accent-color: #1976d2;
--success-color: #4caf50;
--warning-color: #ff9800;
--error-color: #f44336;
}
body.dark-mode {
--bg-primary: #212121;
--bg-secondary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--border-color: #424242;
--accent-color: #64b5f6;
--success-color: #81c784;
--warning-color: #ffb74d;
--error-color: #e57373;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 20px;
transition: background 0.3s, color 0.3s;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: var(--bg-secondary);
padding: 30px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 2rem;
margin-bottom: 10px;
color: var(--accent-color);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.metric-card {
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
border-left: 4px solid var(--accent-color);
}
.metric-card.success {
border-left-color: var(--success-color);
}
.metric-card.warning {
border-left-color: var(--warning-color);
}
.metric-label {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 5px;
}
.metric-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--text-primary);
}
.controls {
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
background: var(--accent-color);
color: white;
cursor: pointer;
font-size: 0.9rem;
transition: transform 0.2s, opacity 0.2s;
}
.btn:hover {
transform: translateY(-2px);
opacity: 0.9;
}
.btn:active {
transform: translateY(0);
}
.btn.secondary {
background: var(--text-secondary);
}
.plan-container {
background: var(--bg-secondary);
padding: 30px;
border-radius: 10px;
margin-bottom: 20px;
overflow-x: auto;
}
.plan-node {
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: 8px;
padding: 15px;
margin: 10px 0;
margin-left: 30px;
position: relative;
transition: transform 0.2s, box-shadow 0.2s;
}
.plan-node:hover {
transform: translateX(5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
border-color: var(--accent-color);
}
.plan-node::before {
content: '';
position: absolute;
left: -30px;
top: 50%;
width: 20px;
height: 2px;
background: var(--border-color);
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.node-operation {
font-weight: bold;
font-size: 1.1rem;
color: var(--accent-color);
}
.node-stats {
display: flex;
gap: 15px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.node-detail {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 5px 0;
}
.feature-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
margin-top: 20px;
}
.feature-card {
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
border-left: 4px solid var(--success-color);
}
.feature-name {
font-weight: bold;
margin-bottom: 10px;
color: var(--text-primary);
}
.feature-benefit {
font-size: 0.9rem;
color: var(--text-secondary);
}
.savings {
display: inline-block;
margin-top: 10px;
padding: 5px 10px;
background: var(--success-color);
color: white;
border-radius: 4px;
font-size: 0.85rem;
font-weight: bold;
}
@media (prefers-color-scheme: dark) {
body:not(.light-mode) {
--bg-primary: #212121;
--bg-secondary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--border-color: #424242;
}
}
@media (max-width: 768px) {
.controls {
flex-direction: column;
align-items: stretch;
}
.btn {
width: 100%;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
/* Accessibility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
*:focus {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
/* Animations */
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
"#.to_string()
}
fn generate_header(&self, output: &ExplainOutput) -> String {
format!(r#"
<div class="container">
<header class="header">
<h1>HeliosDB EXPLAIN Analysis</h1>
<p class="text-secondary">Query Plan Visualization and Performance Insights</p>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Total Cost</div>
<div class="metric-value">{:.2}</div>
</div>
<div class="metric-card success">
<div class="metric-label">Estimated Rows</div>
<div class="metric-value">{}</div>
</div>
<div class="metric-card">
<div class="metric-label">Planning Time</div>
<div class="metric-value">{:.2}ms</div>
</div>
<div class="metric-card success">
<div class="metric-label">Active Features</div>
<div class="metric-value">{}</div>
</div>
</div>
</header>
"#, output.total_cost, output.total_rows, output.planning_time_ms, output.features.len())
}
fn generate_controls(&self) -> String {
r#"
<div class="controls">
<button class="btn" onclick="toggleTheme()" aria-label="Toggle dark mode">
<span id="theme-icon">🌙</span> Toggle Theme
</button>
<button class="btn secondary" onclick="exportPNG()" aria-label="Export as PNG">
📸 Export PNG
</button>
<button class="btn secondary" onclick="exportSVG()" aria-label="Export as SVG">
🎨 Export SVG
</button>
<button class="btn secondary" onclick="exportPDF()" aria-label="Export as PDF">
📄 Export PDF
</button>
<button class="btn secondary" onclick="copyToClipboard()" aria-label="Copy to clipboard">
📋 Copy
</button>
</div>
"#.to_string()
}
fn generate_plan_visualization(&self, output: &ExplainOutput) -> String {
let mut html = String::new();
html.push_str(" <div class=\"plan-container\">\n");
html.push_str(" <h2>Query Execution Plan</h2>\n");
html.push_str(&Self::render_plan_node(&output.plan));
html.push_str(" </div>\n");
html
}
fn render_plan_node(node: &super::explain::PlanNode) -> String {
let mut html = String::new();
html.push_str(" <div class=\"plan-node fade-in\">\n");
html.push_str(" <div class=\"node-header\">\n");
html.push_str(&format!(" <span class=\"node-operation\">{}</span>\n", node.operation));
html.push_str(" <div class=\"node-stats\">\n");
html.push_str(&format!(" <span title=\"Estimated cost\">Cost: {:.2}</span>\n", node.cost));
html.push_str(&format!(" <span title=\"Estimated rows\">Rows: {}</span>\n", node.rows));
html.push_str(" </div>\n");
html.push_str(" </div>\n");
if !node.details.is_empty() {
for (key, value) in &node.details {
html.push_str(&format!(" <div class=\"node-detail\"><strong>{}:</strong> {}</div>\n", key, value));
}
}
for child in &node.children {
html.push_str(&Self::render_plan_node(child));
}
html.push_str(" </div>\n");
html
}
fn generate_metrics(&self, output: &ExplainOutput) -> String {
let mut html = String::new();
if let Some(ai) = &output.ai_explanation {
html.push_str(" <div class=\"plan-container\">\n");
html.push_str(" <h2>AI-Powered Explanation</h2>\n");
html.push_str(&format!(" <p>{}</p>\n", ai.summary));
if !ai.suggestions.is_empty() {
html.push_str(" <h3>Suggestions</h3>\n");
html.push_str(" <ul>\n");
for suggestion in &ai.suggestions {
html.push_str(&format!(" <li>{}</li>\n", suggestion));
}
html.push_str(" </ul>\n");
}
html.push_str(" </div>\n");
}
html
}
fn generate_features(&self, output: &ExplainOutput) -> String {
if output.features.is_empty() {
return String::new();
}
let mut html = String::new();
html.push_str(" <div class=\"plan-container\">\n");
html.push_str(" <h2>Active Optimizer Features</h2>\n");
html.push_str(" <div class=\"feature-list\">\n");
for feature in &output.features {
html.push_str(" <div class=\"feature-card\">\n");
html.push_str(&format!(" <div class=\"feature-name\">{}</div>\n", feature.name));
html.push_str(&format!(" <div class=\"feature-benefit\">{}</div>\n", feature.benefit));
if let Some(savings) = feature.savings_percent {
html.push_str(&format!(" <span class=\"savings\">-{:.1}% Cost</span>\n", savings));
}
html.push_str(" </div>\n");
}
html.push_str(" </div>\n");
html.push_str(" </div>\n");
html
}
fn generate_javascript(&self) -> String {
r#"
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
const icon = document.getElementById('theme-icon');
icon.textContent = document.body.classList.contains('dark-mode') ? '☀️' : '🌙';
}
function exportPNG() {
alert('PNG export functionality would use html2canvas or similar library');
}
function exportSVG() {
alert('SVG export functionality would convert the plan to SVG format');
}
function exportPDF() {
alert('PDF export functionality would use jsPDF or similar library');
}
function copyToClipboard() {
const planText = document.querySelector('.plan-container').innerText;
navigator.clipboard.writeText(planText)
.then(() => alert('Copied to clipboard!'))
.catch(err => alert('Failed to copy: ' + err));
}
// Auto-detect theme preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark-mode');
document.getElementById('theme-icon').textContent = '☀️';
}
// Add animation on scroll
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('fade-in');
}
});
});
document.querySelectorAll('.plan-node').forEach(node => {
observer.observe(node);
});
</script>
</div>
"#.to_string()
}
pub fn render_svg(&self, output: &ExplainOutput) -> String {
let mut svg = String::new();
svg.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
svg.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"800\" height=\"600\">\n");
svg.push_str(" <defs>\n");
svg.push_str(" <style>\n");
svg.push_str(" .node { fill: #e3f2fd; stroke: #1976d2; stroke-width: 2; }\n");
svg.push_str(" .node-text { font-family: Arial; font-size: 14px; }\n");
svg.push_str(" .edge { stroke: #757575; stroke-width: 1; fill: none; }\n");
svg.push_str(" </style>\n");
svg.push_str(" </defs>\n");
svg.push_str(&Self::render_svg_node(&output.plan, 400, 50));
svg.push_str("</svg>\n");
svg
}
fn render_svg_node(node: &super::explain::PlanNode, x: i32, y: i32) -> String {
let mut svg = String::new();
svg.push_str(&format!(
" <rect class=\"node\" x=\"{}\" y=\"{}\" width=\"200\" height=\"60\" rx=\"5\"/>\n",
x - 100, y
));
svg.push_str(&format!(
" <text class=\"node-text\" x=\"{}\" y=\"{}\" text-anchor=\"middle\">{}</text>\n",
x, y + 20, node.operation
));
svg.push_str(&format!(
" <text class=\"node-text\" x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"12\">Cost: {:.2} | Rows: {}</text>\n",
x, y + 40, node.cost, node.rows
));
let child_y = y + 100;
let child_spacing = 250;
let start_x = x - ((node.children.len() as i32 - 1) * child_spacing) / 2;
for (i, child) in node.children.iter().enumerate() {
let child_x = start_x + (i as i32 * child_spacing);
svg.push_str(&format!(
" <line class=\"edge\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"/>\n",
x, y + 60, child_x, child_y
));
svg.push_str(&Self::render_svg_node(child, child_x, child_y));
}
svg
}
}
pub enum ExportFormat {
PNG,
SVG,
PDF,
HTML,
JSON,
}
impl ExportFormat {
pub fn export(output: &ExplainOutput, format: ExportFormat) -> String {
match format {
ExportFormat::HTML => {
let renderer = WebUIRenderer::new(WebUIConfig::default());
renderer.render_html(output)
}
ExportFormat::SVG => {
let renderer = WebUIRenderer::new(WebUIConfig::default());
renderer.render_svg(output)
}
ExportFormat::JSON => {
serde_json::to_string_pretty(output).unwrap_or_default()
}
ExportFormat::PNG | ExportFormat::PDF => {
"PNG/PDF export requires additional rendering libraries".to_string()
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::{Schema, Column, DataType};
use crate::sql::logical_plan::LogicalPlan;
use std::sync::Arc;
use super::super::explain::*;
fn create_test_output() -> ExplainOutput {
let schema = Arc::new(Schema {
columns: vec![
Column {
name: "id".to_string(),
data_type: DataType::Int4,
nullable: false,
primary_key: true,
source_table: None,
source_table_name: None,
default_expr: None,
unique: false,
storage_mode: crate::ColumnStorageMode::Default,
},
],
});
let plan = LogicalPlan::Scan {
table_name: "users".to_string(),
alias: None,
schema,
projection: None,
as_of: None,
};
let planner = ExplainPlanner::new(ExplainMode::AI, ExplainFormat::Text);
planner.explain(&plan).unwrap()
}
#[test]
fn test_html_rendering() {
let renderer = WebUIRenderer::new(WebUIConfig::default());
let output = create_test_output();
let html = renderer.render_html(&output);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("HeliosDB EXPLAIN Analysis"));
assert!(html.contains("Query Execution Plan"));
}
#[test]
fn test_svg_rendering() {
let renderer = WebUIRenderer::new(WebUIConfig::default());
let output = create_test_output();
let svg = renderer.render_svg(&output);
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_dark_mode_config() {
let mut config = WebUIConfig::default();
config.theme = Theme::Dark;
let renderer = WebUIRenderer::new(config);
let output = create_test_output();
let html = renderer.render_html(&output);
assert!(html.contains("dark-mode"));
}
#[test]
fn test_export_formats() {
let output = create_test_output();
let html = ExportFormat::export(&output, ExportFormat::HTML);
assert!(html.contains("<!DOCTYPE html>"));
let svg = ExportFormat::export(&output, ExportFormat::SVG);
assert!(svg.contains("<svg"));
let json = ExportFormat::export(&output, ExportFormat::JSON);
assert!(json.contains("total_cost"));
}
}