magic-dashboard 0.1.0

Auto-generate a real-time web dashboard from Rust structs
Documentation
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Magic Dashboard</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
    <style>
        body { font-family: 'Inter', sans-serif; background-color: #0f172a; color: #f8fafc; }
        .glass-card {
            background: rgba(30, 41, 59, 0.7);
            backdrop-filter: blur(10px);
            border: 1px solid rgba(255, 255, 255, 0.1);
        }
    </style>
</head>
<body class="min-h-screen p-8">
    <div class="max-w-5xl mx-auto">
        <header class="flex justify-between items-center mb-8">
            <div>
                <h1 class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">
                    Magic Dashboard
                </h1>
                <p class="text-slate-400 text-sm mt-1">Live Application State</p>
            </div>
            <div class="flex items-center space-x-2">
                <span class="relative flex h-3 w-3">
                  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
                  <span class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
                </span>
                <span class="text-sm font-medium text-emerald-400" id="status-text">Connected</span>
            </div>
        </header>

        <div id="tabs-container" class="flex space-x-2 mb-6 overflow-x-auto pb-2 border-slate-800">
            <!-- Tabs dynamically injected here -->
        </div>

        <div id="dashboard-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            <!-- Dynamically generated cards will appear here -->
        </div>
    </div>

    <script>
        const grid = document.getElementById('dashboard-grid');
        const statusText = document.getElementById('status-text');
        const tabsContainer = document.getElementById('tabs-container');
        
        let stateData = {};
        let metadata = {};
        let historyData = {}; // { field_name: [val1, val2, ...] }
        let currentTab = "All";
        let isEditing = {}; // { field_name: boolean }
        
        function formatValue(val) {
            if (typeof val === 'boolean') {
                return val 
                    ? `<span class="px-2 py-1 rounded text-xs font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">TRUE</span>`
                    : `<span class="px-2 py-1 rounded text-xs font-semibold bg-rose-500/20 text-rose-400 border border-rose-500/30">FALSE</span>`;
            }
            if (typeof val === 'number') {
                return `<span class="text-blue-400 font-mono text-2xl">${val}</span>`;
            }
            if (typeof val === 'object' && val !== null) {
                return `<pre class="text-xs text-slate-300 bg-slate-800/50 p-3 rounded-lg mt-2 overflow-x-auto border border-slate-700">${JSON.stringify(val, null, 2)}</pre>`;
            }
            return `<span class="text-slate-200 font-medium">${val}</span>`;
        }

        function drawSparkline(values) {
            if (values.length < 2) return '';
            const max = Math.max(...values);
            const min = Math.min(...values);
            const range = max - min || 1;
            const width = 100;
            const height = 20;
            
            const points = values.map((val, i) => {
                const x = (i / (values.length - 1)) * width;
                const y = height - ((val - min) / range) * height;
                return `${x},${y}`;
            }).join(' ');

            return `
            <div class="mt-4 border-t border-slate-700/50 pt-3">
                <svg viewBox="-2 -2 104 24" class="w-full h-8 overflow-visible">
                    <polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-80 drop-shadow-[0_0_2px_rgba(59,130,246,0.5)]"></polyline>
                </svg>
            </div>`;
        }

        async function fetchMetadata() {
            try {
                const response = await fetch('/api/metadata');
                if (response.ok) {
                    metadata = await response.json();
                    renderTabs();
                }
            } catch (e) {
                console.warn('Could not fetch metadata', e);
            }
        }

        function renderTabs() {
            const categories = new Set(["All"]);
            for (const field in metadata) {
                if (metadata[field].category) {
                    categories.add(metadata[field].category);
                }
            }
            
            if (categories.size <= 2) {
                // Don't show tabs if there are no custom categories
                tabsContainer.innerHTML = '';
                return;
            }

            tabsContainer.innerHTML = '';
            categories.forEach(cat => {
                const btn = document.createElement('button');
                const isActive = cat === currentTab;
                btn.className = `px-4 py-2 rounded-lg text-sm font-medium transition-colors ${isActive ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'}`;
                btn.textContent = cat;
                btn.onclick = () => {
                    currentTab = cat;
                    renderTabs(); 
                    renderGrid(); 
                };
                tabsContainer.appendChild(btn);
            });
        }
        
        async function updateField(field, value) {
            try {
                const res = await fetch('/api/state', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ field, value })
                });
                if (!res.ok) {
                    const text = await res.text();
                    alert("Update failed: " + text);
                } else {
                    isEditing[field] = false;
                    fetchState();
                }
            } catch (e) {
                alert("Error: " + e.message);
            }
        }

        window.toggleEdit = function(field) {
            isEditing[field] = !isEditing[field];
            renderGrid();
        }

        window.saveEdit = function(field, typeStr) {
            let inputEl = document.getElementById(`input-${field}`);
            let val = inputEl.value;
            
            if (typeStr === 'number') {
                val = Number(val);
            } else if (typeStr === 'boolean') {
                val = inputEl.checked;
            } else if (typeStr === 'object') {
                try {
                    val = JSON.parse(val);
                } catch(e) {
                    alert("Invalid JSON format");
                    return;
                }
            }
            updateField(field, val);
        }

        function renderGrid() {
            grid.innerHTML = '';
            
            for (const [key, value] of Object.entries(stateData)) {
                let cat = metadata[key]?.category || "General";
                if (currentTab !== "All" && cat !== currentTab) {
                    continue;
                }
                
                if (typeof value === 'number') {
                    if (!historyData[key]) historyData[key] = [];
                    // Only push if not currently editing to prevent jitter
                    if (!isEditing[key]) {
                        historyData[key].push(value);
                        if (historyData[key].length > 30) historyData[key].shift();
                    }
                }

                const card = document.createElement('div');
                card.className = 'glass-card rounded-xl p-6 hover:border-blue-500/50 transition-colors duration-300 shadow-lg relative group flex flex-col justify-between';
                
                const title = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
                let typeStr = typeof value;
                
                if (isEditing[key]) {
                    let inputHtml = '';
                    if (typeStr === 'boolean') {
                        inputHtml = `<label class="flex items-center space-x-2 mt-2 cursor-pointer"><input type="checkbox" id="input-${key}" ${value ? 'checked' : ''} class="w-5 h-5 rounded bg-slate-700 border-slate-600 text-blue-500 focus:ring-blue-500"><span class="text-sm text-slate-300">Enabled</span></label>`;
                    } else if (typeStr === 'number') {
                        inputHtml = `<input type="number" id="input-${key}" value="${value}" step="any" class="w-full bg-slate-800 border border-slate-600 focus:border-blue-500 outline-none rounded px-3 py-2 mt-2 text-slate-200">`;
                    } else if (typeStr === 'object' && value !== null) {
                        inputHtml = `<textarea id="input-${key}" class="w-full bg-slate-800 border border-slate-600 focus:border-blue-500 outline-none rounded px-3 py-2 mt-2 text-slate-200 font-mono text-xs" rows="5">${JSON.stringify(value, null, 2)}</textarea>`;
                    } else {
                        inputHtml = `<input type="text" id="input-${key}" value="${value}" class="w-full bg-slate-800 border border-slate-600 focus:border-blue-500 outline-none rounded px-3 py-2 mt-2 text-slate-200">`;
                    }

                    card.innerHTML = `
                        <div>
                            <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
                                <svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
                                Edit ${title}
                            </h3>
                            ${inputHtml}
                        </div>
                        <div class="flex gap-2 mt-4 pt-4 border-t border-slate-700/50">
                            <button onclick="saveEdit('${key}', '${typeStr}')" class="flex-1 py-1.5 bg-emerald-600/80 hover:bg-emerald-500 text-white text-xs font-semibold rounded transition-colors">Save</button>
                            <button onclick="toggleEdit('${key}')" class="flex-1 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 text-xs font-semibold rounded transition-colors">Cancel</button>
                        </div>
                    `;
                } else {
                    let sparklineHtml = '';
                    if (typeStr === 'number' && historyData[key]) {
                        sparklineHtml = drawSparkline(historyData[key]);
                    }
                    
                    card.innerHTML = `
                        <div>
                            <div class="flex justify-between items-start mb-3">
                                <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
                                    <svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
                                    ${title}
                                </h3>
                                <button onclick="toggleEdit('${key}')" class="opacity-0 group-hover:opacity-100 transition-opacity text-slate-400 hover:text-blue-400 p-1 -mt-1 -mr-1" title="Edit value">
                                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
                                </button>
                            </div>
                            <div class="mt-1">${formatValue(value)}</div>
                        </div>
                        ${sparklineHtml}
                    `;
                }
                grid.appendChild(card);
            }
        }

        async function fetchState() {
            try {
                const response = await fetch('/api/state');
                if (!response.ok) throw new Error('Network response was not ok');
                stateData = await response.json();
                
                statusText.textContent = "Connected";
                statusText.className = "text-sm font-medium text-emerald-400";

                renderGrid();
            } catch (error) {
                console.error('Failed to fetch state:', error);
                statusText.textContent = "Disconnected";
                statusText.className = "text-sm font-medium text-rose-400";
            }
        }

        fetchMetadata().then(() => {
            fetchState();
            setInterval(() => {
                // Only poll if there are no active edits to prevent losing input focus/data
                if (Object.values(isEditing).every(v => !v)) {
                    fetchState();
                }
            }, 500);
        });
    </script>
</body>
</html>