<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rekuiper — Edge Stream & IIoT Console</title>
<style>
:root {
--bg-canvas: #F8FAFC;
--bg-surface: #FFFFFF;
--bg-subtle: #F1F5F9;
--bg-hover: #F8FAFC;
--border: #E2E8F0;
--border-strong: #CBD5E1;
--text-primary: #0F172A;
--text-secondary: #475569;
--text-muted: #64748B;
--text-faint: #94A3B8;
--accent: #2563EB;
--accent-hover: #1D4ED8;
--accent-soft: #EFF6FF;
--accent-border: #BFDBFE;
--emerald-text: #065F46;
--emerald-bg: #ECFDF5;
--emerald-border: #A7F3D0;
--emerald-dot: #10B981;
--indigo-text: #3730A3;
--indigo-bg: #EEF2FF;
--indigo-border: #C7D2FE;
--amber-text: #92400E;
--amber-bg: #FFFBEB;
--amber-border: #FDE68A;
--rose-text: #991B1B;
--rose-bg: #FEF2F2;
--rose-border: #FECACA;
--rose-dot: #EF4444;
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.03);
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 2px rgba(15, 23, 42, 0.03);
--shadow-md: 0 4px 12px -2px rgba(15, 23, 42, 0.06), 0 2px 4px -2px rgba(15, 23, 42, 0.03);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-canvas);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.tabular {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
.topbar {
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 30;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-logo {
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%);
display: flex;
align-items: center;
justify-content: center;
color: #FFFFFF;
font-weight: 700;
font-size: 15px;
letter-spacing: -0.5px;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.25);
}
.brand-title {
font-weight: 700;
font-size: 16px;
letter-spacing: -0.02em;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.brand-subtitle {
font-size: 12px;
color: var(--text-muted);
font-weight: 400;
border-left: 1px solid var(--border);
padding-left: 10px;
}
.topbar-controls {
display: flex;
align-items: center;
gap: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
border: 1px solid transparent;
white-space: nowrap;
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.badge-emerald {
background: var(--emerald-bg);
color: var(--emerald-text);
border-color: var(--emerald-border);
}
.badge-emerald .badge-dot { background: var(--emerald-dot); }
.badge-indigo {
background: var(--indigo-bg);
color: var(--indigo-text);
border-color: var(--indigo-border);
}
.badge-amber {
background: var(--amber-bg);
color: var(--amber-text);
border-color: var(--amber-border);
}
.badge-amber .badge-dot { background: #F59E0B; }
.badge-rose {
background: var(--rose-bg);
color: var(--rose-text);
border-color: var(--rose-border);
}
.badge-rose .badge-dot { background: var(--rose-dot); }
.badge-slate {
background: var(--bg-subtle);
color: var(--text-secondary);
border-color: var(--border);
}
.badge-slate .badge-dot { background: var(--text-faint); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
box-shadow: var(--shadow-xs);
}
.btn:hover:not(:disabled) {
background: var(--bg-subtle);
border-color: var(--border-strong);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #FFFFFF;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-danger {
background: var(--rose-bg);
color: var(--rose-text);
border-color: var(--rose-border);
}
.btn-danger:hover:not(:disabled) {
background: #FEE2E2;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.select-input, .text-input {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-sans);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.select-input:focus, .text-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.container {
max-width: 1440px;
width: 100%;
margin: 0 auto;
padding: 24px;
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 1200px) {
.kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.kpi-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-xs);
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
position: relative;
overflow: hidden;
}
.kpi-card.alert-card {
border-color: var(--rose-border);
background: linear-gradient(180deg, #FFFFFF 0%, #FFFBFB 100%);
}
.kpi-label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text-primary);
line-height: 1.1;
}
.kpi-sub {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
gap: 16px;
flex-wrap: wrap;
}
.tabs {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-subtle);
padding: 4px;
border-radius: 8px;
border: 1px solid var(--border);
}
.tab-btn {
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
background: var(--bg-surface);
color: var(--text-primary);
box-shadow: var(--shadow-xs);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.workspace {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
align-items: start;
}
.workspace.with-inspector {
grid-template-columns: 1fr 480px;
}
@media (max-width: 1100px) {
.workspace.with-inspector {
grid-template-columns: 1fr;
}
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #FCFDFE;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.table-wrap {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th {
padding: 10px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
background: var(--bg-subtle);
border-bottom: 1px solid var(--border);
user-select: none;
white-space: nowrap;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text-primary);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
tbody tr.clickable-row {
cursor: pointer;
transition: background-color 0.1s ease;
}
tbody tr.clickable-row:hover {
background-color: var(--bg-hover);
}
tbody tr.selected-row {
background-color: var(--accent-soft);
}
.rate-pill {
font-size: 11px;
color: var(--text-muted);
margin-left: 4px;
font-weight: 400;
}
.inspector {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
position: sticky;
top: 80px;
max-height: calc(100vh - 104px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.inspector-body {
padding: 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.code-block {
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
color: #1E293B;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
}
.stage-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.stage-box {
border: 1px solid var(--border);
background: var(--bg-canvas);
border-radius: var(--radius-sm);
padding: 8px 6px;
text-align: center;
}
.stage-box.has-err {
background: var(--rose-bg);
border-color: var(--rose-border);
color: var(--rose-text);
}
.stage-name {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.stage-box.has-err .stage-name {
color: var(--rose-text);
}
.stage-count {
font-size: 15px;
font-weight: 700;
margin-top: 2px;
}
.dlq-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.dlq-item {
border: 1px solid var(--rose-border);
background: #FFFBFB;
border-radius: var(--radius-sm);
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.dlq-item-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--text-muted);
}
.dlq-error-msg {
font-size: 12.5px;
font-weight: 600;
color: var(--rose-text);
font-family: var(--font-mono);
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--text-muted);
}
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
padding: 12px 16px;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
font-size: 13px;
color: var(--text-primary);
min-width: 260px;
animation: slideIn 0.2s ease-out;
}
.toast.toast-error { border-left-color: var(--rose-dot); }
.toast.toast-success { border-left-color: var(--emerald-dot); }
@keyframes slideIn {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.two-col { grid-template-columns: 1fr; }
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
textarea.text-input {
font-family: var(--font-mono);
font-size: 12px;
min-height: 140px;
resize: vertical;
}
.studio-container {
display: flex;
flex-direction: column;
height: calc(100vh - 160px);
min-height: 720px;
background: #080c16;
border: 1px solid #1e293b;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
position: relative;
}
.studio-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 16px;
background: #0f172a;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #f8fafc;
z-index: 20;
}
.studio-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 13.5px;
}
.studio-brand .studio-tag {
font-size: 10.5px;
font-weight: 600;
padding: 2px 7px;
background: rgba(14, 165, 233, 0.15);
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: 4px;
}
.studio-pipeline-select {
background: #1e293b;
color: #f8fafc;
border: 1px solid #334155;
padding: 4px 8px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.studio-pipeline-select:focus {
border-color: #f97316;
}
.studio-ai-bar {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
max-width: 580px;
background: #141e2e;
border: 1px solid #23344d;
border-radius: 8px;
padding: 4px 10px;
transition: all 0.2s ease;
}
.studio-ai-bar:focus-within {
border-color: #a855f7;
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
}
.studio-ai-input {
flex: 1;
background: transparent;
border: none;
color: #f8fafc;
font-size: 12px;
outline: none;
}
.studio-ai-input::placeholder {
color: #64748b;
}
.studio-btn-ai {
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
color: #ffffff;
border: none;
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: opacity 0.15s ease;
white-space: nowrap;
}
.studio-btn-ai:hover {
opacity: 0.9;
}
.studio-actions {
display: flex;
align-items: center;
gap: 6px;
}
.studio-body {
display: flex;
flex: 1;
position: relative;
overflow: hidden;
}
.studio-palette {
width: 250px;
background: #0a0f1d;
border-right: 1px solid rgba(255, 255, 255, 0.07);
display: flex;
flex-direction: column;
user-select: none;
z-index: 10;
}
.palette-search-wrap {
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 8px;
}
.palette-search {
width: 100%;
background: #141e2e;
border: 1px solid #23344d;
border-radius: 6px;
color: #f8fafc;
font-size: 11.5px;
padding: 6px 10px;
outline: none;
transition: border-color 0.15s ease;
}
.palette-search:focus {
border-color: #38bdf8;
}
.palette-filter-chips {
display: flex;
gap: 4px;
}
.palette-chip {
background: #141e2e;
border: 1px solid #23344d;
color: #94a3b8;
border-radius: 4px;
font-size: 10.5px;
font-weight: 600;
padding: 2px 7px;
cursor: pointer;
transition: all 0.15s ease;
}
.palette-chip:hover {
color: #f8fafc;
border-color: #475569;
}
.palette-chip.active {
background: #0284c7;
color: #fff;
border-color: #0284c7;
}
.palette-items-list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.palette-items-list::-webkit-scrollbar,
.studio-drawer::-webkit-scrollbar,
.drawer-body::-webkit-scrollbar {
width: 5px;
}
.palette-items-list::-webkit-scrollbar-thumb,
.studio-drawer::-webkit-scrollbar-thumb,
.drawer-body::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
.palette-cat-header {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
margin: 12px 0 6px 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.palette-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #111827;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
margin-bottom: 6px;
cursor: grab;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
color: #f8fafc;
}
.palette-card:hover {
border-color: rgba(255, 255, 255, 0.18);
background: #182234;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.palette-card-left {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.palette-icon-badge {
width: 26px;
height: 26px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.palette-card-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.palette-card-title {
font-size: 11.5px;
font-weight: 600;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.palette-card-desc {
font-size: 9.5px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.palette-add-btn {
background: transparent;
border: none;
color: #64748b;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: all 0.15s ease;
}
.palette-add-btn:hover {
color: #fb923c;
background: rgba(251, 146, 60, 0.1);
}
.studio-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background-color: #080c16;
background-image: radial-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px);
background-size: 20px 20px;
cursor: grab;
}
.studio-canvas-container:active {
cursor: grabbing;
}
.studio-svg-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
transform-origin: 0 0;
}
.studio-wire {
fill: none;
stroke: #475569;
stroke-width: 2.5;
stroke-linecap: round;
pointer-events: stroke;
cursor: pointer;
transition: stroke 0.15s ease, stroke-width 0.15s ease;
}
.studio-wire:hover {
stroke: #f97316;
stroke-width: 4;
filter: drop-shadow(0 0 6px rgba(249, 115, 22, 0.6));
}
.studio-wire.selected {
stroke: #38bdf8;
stroke-width: 3.5;
filter: drop-shadow(0 0 6px rgba(56, 189, 248, 0.6));
}
.studio-wire.active-stream {
stroke: #f97316;
stroke-width: 2.5;
stroke-dasharray: 6 4;
animation: wireStreamFlow 0.8s linear infinite;
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
}
@keyframes wireStreamFlow {
from { stroke-dashoffset: 20; }
to { stroke-dashoffset: 0; }
}
.node-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #475569;
transition: all 0.2s ease;
display: inline-block;
flex-shrink: 0;
}
.node-status-dot.streaming {
background: #10b981;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.9);
animation: pulseStreaming 1.2s infinite;
}
@keyframes pulseStreaming {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.7; }
}
.node-tap-indicator {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.05em;
padding: 1px 6px;
border-radius: 9999px;
background: #ea580c;
color: #ffffff;
box-shadow: 0 0 8px rgba(234, 88, 12, 0.7);
animation: pulseTap 1.2s ease-in-out infinite;
}
@keyframes pulseTap {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.75; transform: scale(0.95); }
}
.studio-nodes-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
transform-origin: 0 0;
}
.studio-node {
position: absolute;
width: 240px;
background: linear-gradient(145deg, #131c2e, #0c1220);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 10px;
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.04);
color: #f8fafc;
cursor: move;
pointer-events: auto;
user-select: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
font-size: 12px;
}
.studio-node:hover {
border-color: rgba(255, 255, 255, 0.22);
box-shadow: 0 12px 30px -4px rgba(0, 0, 0, 0.7);
}
.studio-node.selected {
border-color: #ea580c;
box-shadow: 0 0 0 2px rgba(234, 88, 12, 0.45), 0 12px 32px -4px rgba(0, 0, 0, 0.8);
}
.node-accent-bar {
height: 3.5px;
width: 100%;
border-top-left-radius: 9px;
border-top-right-radius: 9px;
}
.studio-node-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px 6px;
}
.node-header-left {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.node-icon-box {
width: 28px;
height: 28px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.node-title-group {
display: flex;
flex-direction: column;
min-width: 0;
}
.node-title {
font-weight: 600;
font-size: 12.5px;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-subtitle {
font-family: var(--font-mono);
font-size: 10px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-pulse-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.8);
display: inline-block;
}
.studio-node-body {
padding: 4px 12px 8px;
}
.node-summary-chip {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 5px;
padding: 3px 8px;
font-family: var(--font-mono);
font-size: 10.5px;
color: #cbd5e1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-summary-chip .chip-key {
color: #64748b;
margin-right: 4px;
}
.node-summary-chip .chip-val {
color: #38bdf8;
}
.studio-node-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
font-size: 10px;
color: #94a3b8;
}
.port-handle {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: #090d16;
border: 2.5px solid #64748b;
cursor: crosshair;
transition: all 0.15s ease;
z-index: 5;
}
.port-handle:hover {
transform: scale(1.35);
box-shadow: 0 0 8px currentColor;
}
.port-handle.input-port {
left: -7px;
border-color: #38bdf8;
color: #38bdf8;
}
.port-handle.output-port {
right: -7px;
border-color: #f97316;
color: #f97316;
}
.port-handle.connected {
border-color: #10b981;
color: #10b981;
background: #064e3b;
}
.node-ports-container {
padding: 4px 12px 8px;
display: flex;
flex-direction: column;
gap: 5px;
}
.node-port-row {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
font-size: 10px;
font-family: var(--font-mono);
color: #cbd5e1;
background: rgba(0,0,0,0.25);
padding: 3px 8px;
border-radius: 4px;
border: 1px solid rgba(255,255,255,0.04);
}
.node-port-row .port-handle {
right: -14px;
top: 50%;
transform: translateY(-50%);
}
.canvas-controls {
position: absolute;
bottom: 16px;
left: 16px;
display: flex;
align-items: center;
gap: 4px;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 10;
}
.canvas-ctl-btn {
background: transparent;
border: none;
color: #94a3b8;
font-size: 13px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.canvas-ctl-btn:hover {
background: #1e293b;
color: #f8fafc;
}
.studio-drawer {
width: 400px;
background: #0b1120;
border-left: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
z-index: 10;
color: #f8fafc;
}
.drawer-tabs {
display: flex;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #0f172a;
}
.drawer-tab {
flex: 1;
padding: 11px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.drawer-tab.active {
color: #f97316;
border-bottom: 2px solid #ea580c;
background: #131c2e;
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.drawer-pane {
display: none;
}
.drawer-pane.active {
display: block;
}
.studio-code-input {
width: 100%;
background: #060911;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 10px 12px;
color: #38bdf8;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
outline: none;
resize: vertical;
transition: border-color 0.15s ease;
}
.studio-code-input:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
}
.sql-snippet-chip {
background: #1e293b;
border: 1px solid #334155;
color: #cbd5e1;
font-size: 10.5px;
font-family: var(--font-mono);
padding: 3px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.sql-snippet-chip:hover {
background: #273549;
color: #f8fafc;
border-color: #38bdf8;
}
.terminal-window {
background: #060911;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: #38bdf8;
max-height: 220px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.json-sync-textarea {
width: 100%;
height: 380px;
background: #060911;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 12px;
color: #f8fafc;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.45;
resize: vertical;
outline: none;
}
.json-sync-textarea:focus {
border-color: #ea580c;
}
.pipeline-container {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
position: relative;
}
.pipeline-step {
display: flex;
align-items: center;
gap: 12px;
z-index: 2;
}
.pipeline-node {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--bg-surface);
border: 2px solid var(--border-strong);
color: var(--text-muted);
font-weight: 700;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: var(--shadow-xs);
}
.pipeline-node.active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.15);
}
.pipeline-node.success {
border-color: var(--emerald-dot);
background: var(--emerald-bg);
color: var(--emerald-text);
}
.pipeline-node.failed {
border-color: var(--rose-dot);
background: var(--rose-bg);
color: var(--rose-text);
}
.pipeline-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.pipeline-sub {
font-size: 11px;
color: var(--text-muted);
}
.pipeline-connector {
flex: 1;
height: 2px;
background: var(--border-strong);
margin: 0 16px;
position: relative;
}
.fleet-modal-backdrop,
.fleet-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.fleet-modal-dialog {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
animation: modalFadeIn 0.15s ease-out;
}
@keyframes modalFadeIn {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.fleet-modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: #FCFDFE;
}
.fleet-modal-body {
padding: 20px;
overflow-y: auto;
}
.fleet-modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--border);
background: var(--bg-canvas);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.designer-stepper {
display: flex;
align-items: center;
background: var(--bg-subtle);
border-bottom: 1px solid var(--border);
padding: 0 16px;
}
.designer-step-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
background: none;
border-top: none;
border-left: none;
border-right: none;
}
.designer-step-tab:hover {
color: var(--text-primary);
}
.designer-step-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: var(--bg-surface);
}
.designer-step-tab.completed {
color: var(--emerald-text);
}
.step-number {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--border);
color: var(--text-muted);
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.designer-step-tab.active .step-number {
background: var(--accent);
color: #fff;
}
.designer-step-tab.completed .step-number {
background: var(--emerald-dot);
color: #fff;
}
.designer-split {
display: grid;
grid-template-columns: 1.25fr 0.95fr;
gap: 20px;
height: 600px;
}
@media (max-width: 1024px) {
.designer-split {
grid-template-columns: 1fr;
height: auto;
}
}
.designer-form-col {
overflow-y: auto;
padding-right: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.designer-preview-col {
background: #090e17;
border: 1px solid #1e293b;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.designer-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #0f172a;
border-bottom: 1px solid #1e293b;
color: #cbd5e1;
font-size: 12px;
}
.designer-preview-body {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: #38bdf8;
white-space: pre-wrap;
word-break: break-all;
}
.designer-preview-footer {
padding: 10px 14px;
background: #0d1424;
border-top: 1px solid #1e293b;
display: flex;
flex-direction: column;
gap: 8px;
}
.sink-item-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px;
box-shadow: var(--shadow-xs);
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.sink-item-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.fields-grid-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.fields-grid-table th {
background: var(--bg-subtle);
padding: 8px 10px;
border: 1px solid var(--border);
text-align: left;
font-weight: 600;
color: var(--text-secondary);
}
.prop-form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.prop-form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.prop-form-group.full-width {
grid-column: 1 / -1;
}
.prop-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.prop-label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 4px;
}
.prop-hint {
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
.prop-password-wrap {
position: relative;
display: flex;
align-items: center;
}
.prop-password-wrap input {
padding-right: 36px;
width: 100%;
}
.prop-eye-btn {
position: absolute;
right: 6px;
background: none;
border: none;
cursor: pointer;
padding: 4px 6px;
font-size: 14px;
color: var(--text-muted);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
}
.prop-eye-btn:hover {
color: var(--text-primary);
background: var(--bg-surface-hover);
}
.prop-switch-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.prop-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.prop-switch input {
opacity: 0;
width: 0;
height: 0;
}
.prop-switch-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: var(--border);
transition: .2s;
border-radius: 20px;
}
.prop-switch-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .2s;
border-radius: 50%;
}
.prop-switch input:checked + .prop-switch-slider {
background-color: var(--accent);
}
.prop-switch input:checked + .prop-switch-slider:before {
transform: translateX(16px);
}
.prop-dual-toggle-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-subtle);
border-radius: 6px;
border: 1px solid var(--border);
margin-bottom: 12px;
}
.confkeys-master-detail {
display: flex;
gap: 16px;
height: 520px;
}
.confkeys-sidebar {
width: 250px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
border-right: 1px solid var(--border);
padding-right: 14px;
}
.confkeys-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.confkey-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-surface);
cursor: pointer;
font-size: 12px;
transition: all 0.15s ease;
}
.confkey-item:hover {
background: var(--bg-surface-hover);
border-color: var(--accent);
}
.confkey-item.active {
background: #eff6ff;
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.preflight-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.preflight-pill.success {
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.preflight-pill.error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.preflight-pill.running {
background: #eff6ff;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.conn-ref-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
cursor: help;
}
.ext-subtabs-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.ext-subtab-btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s ease;
}
.ext-subtab-btn:hover {
background: var(--bg-surface-hover);
color: var(--text-primary);
}
.ext-subtab-btn.active {
background: var(--accent);
color: #ffffff;
border-color: var(--accent);
}
.func-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
max-height: 520px;
overflow-y: auto;
padding: 2px;
}
.func-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
transition: all 0.15s ease;
}
.func-card:hover {
border-color: var(--accent);
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
}
.func-card-sig {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
color: #8b5cf6;
word-break: break-all;
}
.dropzone-box {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 24px;
text-align: center;
background: var(--bg-subtle);
cursor: pointer;
transition: all 0.2s ease;
}
.dropzone-box:hover, .dropzone-box.dragover {
border-color: var(--accent);
background: #eff6ff;
}
.doc-pill-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
color: var(--accent);
text-decoration: none;
padding: 2px 7px;
border-radius: 4px;
background: #eff6ff;
border: 1px solid #bfdbfe;
transition: all 0.15s ease;
cursor: pointer;
}
.doc-pill-link:hover {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.field-pill {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-family: var(--font-mono);
color: #1e293b;
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.field-pill:hover {
background: #e2e8f0;
border-color: #94a3b8;
color: var(--accent);
}
.recipe-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.recipe-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: #eff6ff;
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="brand-logo">rk</div>
<div class="brand-title">
rekuiper
<span class="brand-subtitle">Edge SQL & IIoT Telemetry Engine</span>
</div>
</div>
<div class="topbar-controls">
<span id="conn-badge" class="badge badge-emerald">
<span class="badge-dot"></span>
<span id="conn-text">Connected</span>
</span>
<span id="mode-badge" class="badge badge-slate" title="Runtime Configuration Mode">
Mutable Mode
</span>
<button id="reload-manifest-btn" class="btn btn-sm" style="display: none;" onclick="reloadDeclarativeManifest()">
↻ Reload Manifest
</button>
<button class="btn btn-sm" onclick="openDoc('guide/ui/web_console_user_guide')" title="Open Interactive User Guide & Docs" style="display:inline-flex; align-items:center; gap:5px; background:var(--bg-surface); font-weight: 500;">
<span>📖</span> User Guide
</button>
<button class="btn btn-sm" onclick="openDoc('faq')" title="Open rekuiper FAQ & Troubleshooting Guide" style="display:inline-flex; align-items:center; gap:5px; background:var(--bg-surface); font-weight: 500;">
<span>❓</span> Help & FAQ
</button>
<button id="topbar-auth-btn" class="btn btn-sm" onclick="handleTopbarAuthClick()" title="Console Security & Access Control" style="display:inline-flex; align-items:center; gap:5px; font-size:11px;">
<span id="topbar-auth-icon">🔓</span>
<span id="topbar-auth-label">Public Access</span>
</button>
<select id="poll-interval" class="select-input" onchange="updatePollInterval(this.value)" aria-label="Refresh interval">
<option value="1000">Poll: 1s</option>
<option value="2000" selected>Poll: 2s</option>
<option value="5000">Poll: 5s</option>
<option value="0">Paused</option>
</select>
<button class="btn btn-sm" onclick="refreshAll(true)">Refresh Now</button>
</div>
</header>
<main class="container">
<section id="quickstart-banner" style="background: linear-gradient(135deg, #f0fdf4 0%, #e0f2fe 100%); border: 1px solid #bbf7d0; border-radius: 8px; padding: 14px 18px; margin-bottom: 16px; box-shadow: 0 2px 6px rgba(0,0,0,0.03);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px;">🚀</span>
<div>
<strong style="font-size: 13px; color: #166534;">Zero-Knowledge Guided Pipeline Assistant</strong>
<span style="font-size: 11px; color: #15803d; margin-left: 6px;">Launch a production stream processing pipeline in 3 simple clicks</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<a href="javascript:void(0)" onclick="openDoc('guide/ui/web_console_user_guide#journey-1-1-click-guided-quick-start-pipeline')" style="font-size: 11px; color: #0369a1; text-decoration: none; font-weight: 600;">📖 Walkthrough</a>
<button type="button" class="btn btn-sm" onclick="dismissQuickStartBanner()" style="padding: 2px 8px; font-size: 11px; background: rgba(255,255,255,0.7); border-color: #cbd5e1;">✕ Hide</button>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 12px; align-items: flex-end;">
<div>
<label style="display: block; font-size: 11px; font-weight: 600; color: #166534; margin-bottom: 4px;">1. Ingress Source</label>
<select id="qs-source" class="select-input" style="width: 100%; font-size: 12px; background: #ffffff;">
<option value="simulator">Synthetic Industrial Sensor (Built-in)</option>
<option value="mqtt">MQTT Broker (factory/+/telemetry)</option>
<option value="modbus">Modbus TCP PLC (Port 502)</option>
<option value="opcua">OPC-UA Server (Port 4840)</option>
<option value="httppull">HTTP Pull REST API</option>
</select>
</div>
<div>
<label style="display: block; font-size: 11px; font-weight: 600; color: #166534; margin-bottom: 4px;">2. Analytics Goal</label>
<select id="qs-goal" class="select-input" style="width: 100%; font-size: 12px; background: #ffffff;">
<option value="overheat">Overheat Threshold Alert (> 80°C)</option>
<option value="moving_avg">10-Second Moving Average (Tumbling)</option>
<option value="vibration_anomaly">Vibration Anomaly Spike Detector</option>
<option value="passthrough">Raw Ingestion & Passthrough</option>
</select>
</div>
<div>
<label style="display: block; font-size: 11px; font-weight: 600; color: #166534; margin-bottom: 4px;">3. Egress Destination</label>
<select id="qs-sink" class="select-input" style="width: 100%; font-size: 12px; background: #ffffff;">
<option value="log">Local Edge Log (kuiper.out)</option>
<option value="mqtt">Cloud MQTT Broker (alerts/plant1)</option>
<option value="rest">REST Incident Webhook</option>
</select>
</div>
<button type="button" class="btn btn-primary" onclick="launchGuidedQuickStartPipeline()" style="white-space: nowrap; font-weight: 600; background: #16a34a; border-color: #15803d; height: 32px; display: inline-flex; align-items: center; gap: 6px;">
<span>⚡ Launch Pipeline</span>
</button>
</div>
</section>
<section class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">
<span>ACTIVE RULES</span>
<span class="badge badge-emerald" id="kpi-rules-pct">100%</span>
</div>
<div class="kpi-value tabular" id="kpi-rules-count">0 / 0</div>
<div class="kpi-sub">
<span id="kpi-rules-stopped">0 stopped</span>
<span id="kpi-uptime" class="tabular"></span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">
<span>INGRESS THROUGHPUT</span>
<span style="color: var(--accent); font-weight: 600;">IN</span>
</div>
<div class="kpi-value tabular" id="kpi-in-rate">0 <span style="font-size: 13px; font-weight: 500; color: var(--text-muted);">rec/s</span></div>
<div class="kpi-sub tabular">
<span>Total In:</span>
<strong id="kpi-in-total">0</strong>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">
<span>EGRESS THROUGHPUT</span>
<span style="color: var(--emerald-text); font-weight: 600;">OUT</span>
</div>
<div class="kpi-value tabular" id="kpi-out-rate">0 <span style="font-size: 13px; font-weight: 500; color: var(--text-muted);">rec/s</span></div>
<div class="kpi-sub tabular">
<span>Total Out:</span>
<strong id="kpi-out-total">0</strong>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">
<span>TOPOLOGY CATALOG</span>
</div>
<div class="kpi-value tabular" id="kpi-catalog-count">0</div>
<div class="kpi-sub tabular">
<span id="kpi-streams-sub">0 Streams</span> ·
<span id="kpi-tables-sub">0 Tables</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">
<span>SINK RETRY CACHE</span>
</div>
<div class="kpi-value tabular" id="kpi-cache-depth">0</div>
<div class="kpi-sub tabular">
<span>Filtered: <strong id="kpi-filtered-total">0</strong></span>
</div>
</div>
<div class="kpi-card" id="kpi-dlq-card">
<div class="kpi-label">
<span>DLQ & POISON PILLS</span>
<span class="badge badge-slate" id="kpi-dlq-badge">Clean</span>
</div>
<div class="kpi-value tabular" id="kpi-dlq-total">0</div>
<div class="kpi-sub tabular">
<span>Exceptions: <strong id="kpi-exc-total">0</strong></span>
</div>
</div>
</section>
<section class="nav-bar">
<div class="tabs">
<button class="tab-btn active" id="tab-btn-rules" onclick="switchTab('rules')">
Pipelines & Rules
</button>
<button class="tab-btn" id="tab-btn-catalog" onclick="switchTab('catalog')">
Streams, Schemas & Connections
</button>
<button class="tab-btn" id="tab-btn-plugins" onclick="switchTab('plugins')">
<span style="display:inline-flex; align-items:center; gap:6px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10"></path><path d="M12 20V4"></path><path d="M6 20v-6"></path></svg>
Extensions & Services
</span>
</button>
<button class="tab-btn" id="tab-btn-ingest" onclick="switchTab('ingest')">
OTLP & Data Test Console
</button>
<button class="tab-btn" id="tab-btn-metrics" onclick="switchTab('metrics')">
Prometheus /metrics
</button>
<button class="tab-btn" id="tab-btn-studio" onclick="switchTab('studio')">
<span style="color:#ea580c; font-weight:700;">✨ Visual Stream Studio</span>
</button>
<button class="tab-btn" id="tab-btn-fleet" onclick="switchTab('fleet')">
<span style="display:inline-flex; align-items:center; gap:6px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
Fleet Manager
</span>
</button>
</div>
<div class="filter-group" id="rules-filter-bar">
<input
type="search"
id="rule-search"
class="text-input"
placeholder="Filter rules by ID or status..."
oninput="renderRulesTable()"
style="width: 240px;"
/>
<select id="rule-status-filter" class="select-input" onchange="renderRulesTable()">
<option value="all">All Statuses</option>
<option value="running">Running Only</option>
<option value="stopped">Stopped / Halted</option>
<option value="dlq">Has DLQ / Errors (> 0)</option>
</select>
</div>
</section>
<section id="view-rules" class="workspace">
<div class="card">
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div class="card-title">
Rule Execution Pipelines
<span class="badge badge-slate tabular" id="table-rule-count">0</span>
</div>
<div style="font-size: 12px; color: var(--text-muted);">
Click any rule to inspect SQL, stage counters & Dead-Letter Queue poison pills
</div>
</div>
<div>
<button class="btn btn-sm btn-primary" onclick="openNewRuleDesigner()" style="display: inline-flex; align-items: center; gap: 6px; font-weight: 600;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
+ New Rule Pipeline
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Rule ID</th>
<th>Status</th>
<th>Records In</th>
<th>Records Out</th>
<th>Filtered</th>
<th>Sink Cache</th>
<th>DLQ / Errors</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="rules-tbody">
<tr>
<td colspan="8" class="empty-state">Loading active rules...</td>
</tr>
</tbody>
</table>
</div>
</div>
<aside id="rule-inspector" class="inspector" style="display: none;">
<div class="card-header">
<div class="card-title">
<span id="insp-rule-id" style="font-family: var(--font-mono);">rule_id</span>
<span id="insp-status-badge" class="badge badge-emerald">Running</span>
</div>
<div style="display: flex; gap: 6px;">
<button class="btn btn-sm" onclick="loadRuleIntoStudio(state.selectedRuleId)" style="background:#1e293b; color:#fb923c; border-color:#ea580c; font-weight:600;" title="Open in Visual Studio">Studio 🎨</button>
<button class="btn btn-sm" onclick="closeInspector()" title="Close Inspector">✕</button>
</div>
</div>
<div class="inspector-body">
<div>
<div class="section-title">
<span>Pipeline SQL Definition</span>
<span id="insp-options-summary" style="font-family: var(--font-mono); text-transform: none;"></span>
</div>
<pre class="code-block" id="insp-sql">SELECT * FROM stream</pre>
</div>
<div>
<div class="section-title">
<span>DLQ Poison-Pill Stage Breakdown</span>
<button class="btn btn-danger btn-sm" id="clear-dlq-btn" onclick="clearSelectedRuleDlq()">
Clear DLQ Buffer
</button>
</div>
<div class="stage-grid">
<div class="stage-box" id="st-codec">
<span class="stage-name" title="CodecDecode">Codec</span>
<div class="stage-count tabular" id="st-codec-val">0</div>
</div>
<div class="stage-box" id="st-schema">
<span class="stage-name" title="SchemaValidation">Schema</span>
<div class="stage-count tabular" id="st-schema-val">0</div>
</div>
<div class="stage-box" id="st-sql">
<span class="stage-name" title="SqlEval">SQL Eval</span>
<div class="stage-count tabular" id="st-sql-val">0</div>
</div>
<div class="stage-box" id="st-tpl">
<span class="stage-name" title="DataTemplate">Template</span>
<div class="stage-count tabular" id="st-tpl-val">0</div>
</div>
<div class="stage-box" id="st-sink">
<span class="stage-name" title="SinkDelivery">Sink 4xx</span>
<div class="stage-count tabular" id="st-sink-val">0</div>
</div>
</div>
</div>
<div>
<div class="section-title">
<span>Recent DLQ Envelopes (In-Memory Ring Buffer)</span>
<span class="tabular" id="insp-dlq-count-label">0 entries</span>
</div>
<div id="insp-dlq-list" class="dlq-list">
<div class="empty-state" style="padding: 18px; background: var(--bg-canvas); border-radius: 6px; border: 1px dashed var(--border);">
Zero poison pills recorded for this rule.
</div>
</div>
</div>
<div>
<div class="section-title">
<span>Configured Sink Actions & Options</span>
</div>
<pre class="code-block" id="insp-actions-json">[]</pre>
</div>
</div>
</aside>
</section>
<section id="view-catalog" style="display: none;" class="two-col">
<div style="display: flex; flex-direction: column; gap: 20px;">
<div class="card">
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<div class="card-title">
Declared Streams
<span class="badge badge-slate tabular" id="catalog-streams-count">0</span>
</div>
<div>
<button id="catalog-new-stream-btn" class="btn btn-sm btn-primary" onclick="openNewStreamDesigner('stream')" style="display: inline-flex; align-items: center; gap: 4px; font-weight: 600;">
+ New Stream
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Stream Name</th>
<th>Details / Schema</th>
<th style="text-align: right; width: 130px;">Actions</th>
</tr>
</thead>
<tbody id="streams-tbody">
<tr><td colspan="3" class="empty-state">No streams defined.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card" id="schemas-hub-card">
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>Schema Registry & Serialization Hub</span>
<span class="badge badge-slate tabular" id="catalog-schemas-count">0</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="text" id="catalog-schemas-search" class="text-input" style="padding: 4px 10px; font-size: 12px; width: 150px;" placeholder="Search schemas..." oninput="renderSchemasTable()" />
<select id="catalog-schemas-filter" class="select-input" style="padding: 4px 8px; font-size: 12px;" onchange="renderSchemasTable()">
<option value="all">All Formats</option>
<option value="protobuf">Protobuf 3 (.proto)</option>
<option value="custom">Custom Codecs</option>
<option value="json">JSON Schema</option>
<option value="delimited">Delimited / CSV</option>
</select>
<button class="btn btn-sm btn-primary" onclick="openNewSchemaModal()" title="Register New Schema Definition">
+ New Schema 📜
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 25%;">Schema ID</th>
<th style="width: 18%;">Format / Protocol</th>
<th style="width: 27%;">Contract Symbols / Messages</th>
<th style="width: 16%;">Active References</th>
<th style="width: 14%; text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="schemas-tbody">
<tr><td colspan="5" class="empty-state">Loading schemas...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 20px;">
<div class="card">
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
<div class="card-title">
Lookup & Scan Tables (Modbus / OPC-UA / SQL / File)
<span class="badge badge-slate tabular" id="catalog-tables-count">0</span>
</div>
<div>
<button class="btn btn-sm btn-primary" onclick="openNewStreamDesigner('table')" style="display: inline-flex; align-items: center; gap: 4px; font-weight: 600;">
+ New Table
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Table Name</th>
<th>Details</th>
<th style="text-align: right; width: 130px;">Actions</th>
</tr>
</thead>
<tbody id="tables-tbody">
<tr><td colspan="3" class="empty-state">No tables defined.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>Shared Connection Pool Hub</span>
<span class="badge badge-slate tabular" id="catalog-conns-count">0</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="text" id="catalog-conns-search" class="text-input" style="padding: 4px 10px; font-size: 12px; width: 170px;" placeholder="Search connections..." oninput="renderConnectionsTable()" />
<button class="btn btn-sm" onclick="openConfKeysManagerModal()" title="Manage Connector Configuration Keys">
Manage ConfKeys 🔑
</button>
<button class="btn btn-sm btn-primary" onclick="openNewConnectionModal()" title="Add New Shared Connection">
+ New Connection 🔌
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 22%;">Connection ID</th>
<th style="width: 20%;">Type & Target</th>
<th style="width: 22%;">Active Pipeline References</th>
<th style="width: 22%;">Configuration Preview</th>
<th style="width: 14%; text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="conns-tbody">
<tr><td colspan="5" class="empty-state">No shared connections configured.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section id="view-plugins" style="display: none;" class="workspace">
<div class="ext-subtabs-bar">
<button class="ext-subtab-btn active" id="ext-subtab-btn-plugins" onclick="switchExtSubTab('plugins')">
<span>🔌 Native & Wasm Plugins</span>
<span class="badge badge-slate tabular" id="ext-count-plugins">0</span>
</button>
<button class="ext-subtab-btn" id="ext-subtab-btn-udfs" onclick="switchExtSubTab('udfs')">
<span>⚡ JavaScript UDFs & Functions</span>
<span class="badge badge-slate tabular" id="ext-count-udfs">0</span>
</button>
<button class="ext-subtab-btn" id="ext-subtab-btn-services" onclick="switchExtSubTab('services')">
<span>🌐 External Services & RPC</span>
<span class="badge badge-slate tabular" id="ext-count-services">0</span>
</button>
<button class="ext-subtab-btn" id="ext-subtab-btn-uploads" onclick="switchExtSubTab('uploads')">
<span>📁 File Storage & Artifacts</span>
<span class="badge badge-slate tabular" id="ext-count-uploads">0</span>
</button>
<button class="ext-subtab-btn" id="ext-subtab-btn-backup" onclick="switchExtSubTab('backup')">
<span>💾 Data Backup & Migration</span>
</button>
<div style="margin-left: auto; font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 6px;">
<span class="badge badge-emerald">Single-Binary Runtime</span>
</div>
</div>
<div id="ext-panel-plugins" style="display: flex; flex-direction: column; gap: 16px;">
<div class="card">
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>Native & WebAssembly (Wasm) Plugins</span>
<span class="badge badge-slate tabular" id="plugins-table-count">0</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="text" id="plugins-search-input" class="text-input" style="padding: 4px 10px; font-size: 12px; width: 170px;" placeholder="Search plugins..." oninput="renderPluginsTable()" />
<select id="plugins-type-filter" class="select-input" style="padding: 4px 8px; font-size: 12px;" onchange="renderPluginsTable()">
<option value="all">All Types</option>
<option value="source">Sources 📥</option>
<option value="sink">Sinks 📤</option>
<option value="function">Functions ⚡</option>
<option value="wasm">Wasm 🦀</option>
<option value="portable">Portables 📦</option>
</select>
<button class="btn btn-sm btn-primary" onclick="openPluginInstallModal()">
+ Register Plugin 🔌
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 24%;">Plugin Name</th>
<th style="width: 16%;">Type / Target</th>
<th style="width: 24%;">Exported Symbols / Codec</th>
<th style="width: 18%;">Runtime Mode</th>
<th style="width: 18%; text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="plugins-tbody">
<tr><td colspan="5" class="empty-state">Loading plugins...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="ext-panel-udfs" style="display: none; flex-direction: column; gap: 16px;">
<div class="two-col">
<div class="card">
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>JavaScript User-Defined Functions (UDF)</span>
<span class="badge badge-slate tabular" id="udfs-table-count">0</span>
</div>
<button class="btn btn-sm btn-primary" onclick="openJsUdfModal()">
+ New JS Function ⚡
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 30%;">Function ID</th>
<th style="width: 20%;">Kind</th>
<th style="width: 32%;">Description</th>
<th style="width: 18%; text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="udfs-tbody">
<tr><td colspan="4" class="empty-state">Loading JavaScript UDFs...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>SQL Function Palette & Reference</span>
<span class="badge badge-indigo tabular" id="sql-funcs-count">221</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<input type="text" id="sql-funcs-search" class="text-input" style="padding: 4px 8px; font-size: 11px; width: 130px;" placeholder="Search function..." oninput="renderSqlFunctionsPalette()" />
<select id="sql-funcs-category" class="select-input" style="padding: 4px 6px; font-size: 11px;" onchange="renderSqlFunctionsPalette()">
<option value="all">All Categories</option>
<option value="math">Math</option>
<option value="string">String</option>
<option value="aggregate">Aggregate</option>
<option value="datetime">Date & Time</option>
<option value="conversion">Conversion</option>
<option value="array">Array</option>
<option value="object">Object / JSON</option>
<option value="binary">Binary</option>
<option value="misc">Misc / System</option>
</select>
</div>
</div>
<div style="padding: 14px;">
<div class="func-card-grid" id="sql-funcs-grid">
<div style="font-size: 12px; color: var(--text-muted);">Loading SQL functions catalog...</div>
</div>
</div>
</div>
</div>
</div>
<div id="ext-panel-services" style="display: none; flex-direction: column; gap: 16px;">
<div class="card">
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>External Services & RPC Registrations</span>
<span class="badge badge-slate tabular" id="services-table-count">0</span>
</div>
<button class="btn btn-sm btn-primary" onclick="openServiceRegisterModal()">
+ Register Service 🌐
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 25%;">Service Name</th>
<th style="width: 30%;">Target Endpoint / Address</th>
<th style="width: 27%;">Exported Interfaces / Methods</th>
<th style="width: 18%; text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="services-tbody">
<tr><td colspan="4" class="empty-state">Loading services...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>Discovered Service Methods (Available in SQL Projections & Predicates)</span>
<span class="badge badge-slate tabular" id="service-funcs-count">0</span>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 25%;">Function Name (SQL)</th>
<th style="width: 25%;">Service</th>
<th style="width: 25%;">Interface</th>
<th style="width: 25%;">Remote Method & Target</th>
</tr>
</thead>
<tbody id="service-funcs-tbody">
<tr><td colspan="4" class="empty-state">No service functions discovered.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="ext-panel-uploads" style="display: none; flex-direction: column; gap: 16px;">
<div class="card">
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="card-title" style="display: flex; align-items: center; gap: 8px;">
<span>Edge Configuration & Artifact Storage</span>
<span class="badge badge-slate tabular" id="uploads-table-count">0</span>
</div>
<button class="btn btn-sm btn-primary" onclick="openFileUploadModal()">
Upload Artifact 📁
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 32%;">Artifact Filename</th>
<th style="width: 22%;">Target Edge Path</th>
<th style="width: 26%;">Usage Guidance</th>
<th style="width: 20%; text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="uploads-tbody">
<tr><td colspan="4" class="empty-state">Loading uploaded files...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="ext-panel-backup" style="display: none; flex-direction: column; gap: 16px;">
<div class="two-col">
<div class="card">
<div class="card-header">
<div class="card-title">📦 Configuration & Topology Export</div>
</div>
<div style="padding: 18px; display: flex; flex-direction: column; gap: 14px;">
<p style="font-size: 12px; color: var(--text-secondary); margin: 0;">
Export active streams, lookup tables, rules, plugins, and connections into a unified portable snapshot.
</p>
<div style="display: flex; flex-direction: column; gap: 8px; font-size: 12px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="export-include-all" checked onchange="toggleExportAll(this.checked)" />
<strong>Include Complete Configuration & Pipelines</strong>
</label>
<div style="padding-left: 24px; display: flex; flex-direction: column; gap: 6px; color: var(--text-muted);" id="export-details-box">
<span>✓ Streams & Tables DDL schemas</span>
<span>✓ Rule Execution Pipelines (SQL & Sinks)</span>
<span>✓ Shared Connection Pool & ConfKeys</span>
<span>✓ Protobuf & Custom Codecs</span>
<span>✓ JavaScript User-Defined Functions</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 6px;">
<button type="button" class="btn btn-primary" onclick="triggerDataExport('json')">
Download JSON Snapshot ⬇
</button>
<button type="button" class="btn" onclick="triggerDataExport('yaml')">
Download GitOps YAML ⬇
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">📥 Configuration & Topology Restore</div>
</div>
<div style="padding: 18px; display: flex; flex-direction: column; gap: 14px;">
<p style="font-size: 12px; color: var(--text-secondary); margin: 0;">
Import a previously exported JSON or YAML snapshot. Restores streams, tables, and rules with automatic reconciliation.
</p>
<div class="dropzone-box" id="import-dropzone" onclick="document.getElementById('import-file-input').click()">
<input type="file" id="import-file-input" style="display: none;" accept=".json,.yaml,.yml" onchange="handleImportFileSelect(event)" />
<div style="font-size: 24px; margin-bottom: 6px;">📂</div>
<div style="font-weight: 600; font-size: 13px;" id="import-file-label">Click or Drag & Drop JSON / YAML Backup</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Supports ekuiper export format and GitOps declarative manifests</div>
</div>
<div style="display: flex; align-items: center; justify-content: space-between;">
<label style="font-size: 12px; display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="import-stop-rules" />
<span>Import in stopped state</span>
</label>
<button type="button" class="btn btn-primary" id="import-submit-btn" onclick="submitDataImport()" disabled>
Restore & Apply Snapshot 🚀
</button>
</div>
<div id="import-status-box" style="display: none; padding: 10px 12px; border-radius: 6px; font-size: 12px; font-family: var(--font-mono); line-height: 1.4;"></div>
</div>
</div>
</div>
</div>
</section>
<section id="view-ingest" style="display: none;" class="two-col">
<div class="card">
<div class="card-header">
<div class="card-title">Data-Plane & OTLP Telemetry Injector</div>
</div>
<div style="padding: 18px;">
<div class="form-group">
<label class="form-label" for="ingest-endpoint">Target Ingestion Endpoint</label>
<select id="ingest-endpoint" class="select-input" onchange="loadPresetPayload(this.value)">
<option value="/v1/metrics">POST /v1/metrics (OTLP/HTTP Metrics JSON)</option>
<option value="/v1/logs">POST /v1/logs (OTLP/HTTP Logs JSON)</option>
<option value="/v1/traces">POST /v1/traces (OTLP/HTTP Traces JSON)</option>
<option value="/data/demo">POST /data/{stream} (HTTP Push Stream JSON)</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="ingest-custom-path">Endpoint Path (Editable)</label>
<input type="text" id="ingest-custom-path" class="text-input" value="/v1/metrics" style="font-family: var(--font-mono);" />
</div>
<div class="form-group">
<label class="form-label" for="ingest-payload">JSON Payload</label>
<textarea id="ingest-payload" class="text-input"></textarea>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button class="btn" onclick="loadPresetPayload(document.getElementById('ingest-endpoint').value)">Reset Preset</button>
<button class="btn btn-primary" onclick="sendIngestPayload()">Send Payload</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Server Response</div>
</div>
<div style="padding: 18px;">
<pre class="code-block" id="ingest-response" style="min-height: 310px;">Click "Send Payload" to push a test event into rekuiper's data plane.</pre>
</div>
</div>
</section>
<section id="view-metrics" style="display: none;">
<div class="card">
<div class="card-header">
<div class="card-title">Raw Prometheus Exposition (`GET /metrics`)</div>
<button class="btn btn-sm" onclick="fetchPrometheusMetrics()">Refresh /metrics</button>
</div>
<div style="padding: 18px;">
<pre class="code-block" id="prom-metrics-box" style="max-height: 600px;">Loading /metrics...</pre>
</div>
</div>
</section>
<section id="view-studio" style="display: none;" class="studio-container">
<div class="studio-topbar">
<div class="studio-brand">
<span style="color: #fb923c; display: flex; align-items: center; gap: 6px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
STREAM DAG STUDIO
</span>
<span class="studio-tag">Native Engine</span>
<div style="display: flex; align-items: center; gap: 6px; margin-left: 6px;">
<span style="font-size: 11px; color: #64748b;">Rule:</span>
<input
type="text"
id="studio-rule-id"
class="text-input"
value="stream_pipeline_01"
style="width: 185px; background: #141e2e; color: #f8fafc; border-color: #23344d; padding: 4px 8px; font-family: var(--font-mono); font-size: 12px; border-radius: 6px;"
placeholder="Pipeline / Rule ID"
/>
</div>
</div>
<div class="studio-ai-bar">
<span style="font-size: 14px;">✨</span>
<input
type="text"
id="studio-ai-prompt"
class="studio-ai-input"
placeholder="Describe pipeline (e.g. Read factory MQTT -> rate limit 10/s -> switch temp>80 to alerts_mqtt, else timescaledb)..."
onkeydown="if(event.key==='Enter') synthesizeGraphFromPrompt()"
/>
<button
class="studio-btn-ai"
onclick="synthesizeGraphFromPrompt()"
title="Compile natural language into interactive DAG"
>
✨ Synthesize Pipeline
</button>
</div>
<div class="studio-actions">
<select
id="studio-preset-select"
class="select-input"
onchange="loadStudioPreset(this.value)"
style="background: #141e2e; color: #f8fafc; border-color: #23344d; font-size: 12px; padding: 4px 8px; border-radius: 6px;"
>
<option value="">Load Template...</option>
<option value="iot_switch">IIoT Switch Router</option>
<option value="rate_limited">Rate-Limited Edge Pipeline</option>
<option value="watchdog_alarm">Inactivity Watchdog Alarm</option>
<option value="window_agg">Window Aggregator & SQL</option>
</select>
<button class="btn btn-sm" onclick="autoLayoutNodes()" title="Auto-align nodes into topological columns" style="background:#141e2e; color:#cbd5e1; border-color:#23344d;">
Auto Layout
</button>
<button class="btn btn-sm" onclick="clearStudioCanvas()" title="Clear Canvas" style="background:#141e2e; color:#cbd5e1; border-color:#23344d;">
Clear
</button>
<button class="btn btn-sm" onclick="validateStudioGraph()" title="Validate Topology" style="background:#141e2e; color:#cbd5e1; border-color:#23344d;">
Validate
</button>
<button
class="btn btn-sm"
onclick="deployStudioPipeline()"
style="background: linear-gradient(135deg, #ea580c 0%, #f97316 100%); color: #fff; border: 1px solid #c2410c; font-weight: 600; box-shadow: 0 2px 8px rgba(234,88,12,0.4);"
title="Deploy DAG rule to live Kuiper engine"
>
🚀 Deploy Pipeline
</button>
</div>
</div>
<div class="studio-body">
<aside class="studio-palette" id="studio-palette">
<div class="palette-search-wrap">
<input
type="search"
id="palette-search"
placeholder="Search components..."
oninput="filterPalette(this.value)"
class="palette-search"
/>
<div class="palette-filter-chips">
<button class="palette-chip active" id="chip-all" onclick="setPaletteFilter('all')">All</button>
<button class="palette-chip" id="chip-source" onclick="setPaletteFilter('source')">Sources</button>
<button class="palette-chip" id="chip-operator" onclick="setPaletteFilter('operator')">Process</button>
<button class="palette-chip" id="chip-sink" onclick="setPaletteFilter('sink')">Sinks</button>
</div>
</div>
<div class="palette-items-list" id="palette-items-list">
</div>
</aside>
<div class="studio-canvas-container" id="studio-canvas-container">
<svg id="studio-svg-layer" class="studio-svg-layer">
<defs>
<marker id="arrowhead" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ea580c"/>
</marker>
<marker id="arrowhead-selected" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#fb923c"/>
</marker>
</defs>
<g id="studio-wires-group"></g>
<path id="studio-drag-wire" d="" style="display: none;" stroke="#fb923c" stroke-width="2.5" stroke-dasharray="5 3" fill="none" />
</svg>
<div id="studio-nodes-layer" class="studio-nodes-layer"></div>
<div class="canvas-controls">
<button class="canvas-ctl-btn" onclick="zoomCanvas(0.1)" title="Zoom In">+</button>
<span id="canvas-zoom-level" style="font-family: var(--font-mono); font-size: 11px; color: #94a3b8; padding: 0 4px;">100%</span>
<button class="canvas-ctl-btn" onclick="zoomCanvas(-0.1)" title="Zoom Out">-</button>
<button class="canvas-ctl-btn" onclick="resetCanvasTransform()" title="Reset View">⌂</button>
<button class="canvas-ctl-btn" onclick="autoLayoutNodes()" title="Auto Layout">☰</button>
<span style="border-left: 1px solid #1e293b; margin: 0 4px; height: 14px;"></span>
<span id="canvas-node-counter" style="font-size: 11px; color: #64748b; font-family: var(--font-mono);">0 nodes</span>
</div>
</div>
<aside class="studio-drawer" id="studio-drawer">
<div class="drawer-tabs">
<button class="drawer-tab active" id="drawer-tab-node" onclick="switchDrawerTab('node')">
Node & Wire-Tap
</button>
<button class="drawer-tab" id="drawer-tab-json" onclick="switchDrawerTab('json')">
GraphRule JSON
</button>
</div>
<div class="drawer-body">
<div id="drawer-pane-node" class="drawer-pane active">
<div id="node-inspector-empty" style="text-align: center; color: #64748b; padding: 40px 10px;">
<div style="font-size: 24px; margin-bottom: 8px;">🔍</div>
<div style="font-size: 13px; font-weight: 600; color: #94a3b8; margin-bottom: 4px;">No Node Selected</div>
<div style="font-size: 11.5px;">Click any node on the DAG canvas to edit properties, inspect live metrics, or wire-tap /comms telemetry.</div>
</div>
<div id="node-inspector-content" style="display: none;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<div>
<span id="insp-node-category" class="badge badge-slate" style="font-size: 10px; text-transform: uppercase;"></span>
<h3 id="insp-node-title" style="font-size: 14px; font-weight: 700; color: #f8fafc; margin-top: 4px;"></h3>
</div>
<button class="btn btn-sm" onclick="deleteSelectedNode()" style="background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3);" title="Delete node">
Delete
</button>
</div>
<div class="form-group">
<label class="form-label" style="color: #94a3b8;">Node ID (Topology Key)</label>
<input type="text" id="insp-node-id" class="text-input" style="background: #060911; color: #f8fafc; border-color: #1e293b; font-family: var(--font-mono); font-size: 12px;" onchange="updateSelectedNodeId(this.value)" />
</div>
<div id="insp-node-props-form"></div>
<div class="wire-tap-panel">
<div class="wire-tap-panel-header">
<div style="display: flex; align-items: center; gap: 6px;">
<span style="font-size: 12px; font-weight: 700; color: #f8fafc;">/comms Wire-Tap</span>
<span id="insp-tap-status-pill" class="badge badge-slate" style="font-size: 10px;">Standby</span>
</div>
<button id="insp-tap-toggle-btn" class="btn btn-sm" onclick="toggleSelectedNodeWireTap()" style="background: #ea580c; color: #fff; border: none; font-size: 11px; padding: 3px 8px;">
⚡ Enable Tap
</button>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 11px; color: #94a3b8; font-family: var(--font-mono);">
<span>Token Limit: <strong>20 msg/s</strong></span>
<span id="insp-tap-dropped-badge" style="color: #fb923c;">Dropped: 0</span>
</div>
<div id="insp-connector-status-row" style="display: none; margin-bottom: 8px; font-size: 11px; padding: 6px 8px; background: #060911; border: 1px solid #1e293b; border-radius: 4px;">
<span style="color: #64748b;">Connector Status:</span>
<strong id="insp-connector-status-val" style="color: #10B981; margin-left: 4px;">Connected</strong>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<span style="font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; font-weight: 600;">Live Record Stream</span>
<button style="background: transparent; border: none; color: #64748b; font-size: 10px; cursor: pointer;" onclick="clearTapTerminal()">Clear</button>
</div>
<pre class="terminal-window" id="insp-tap-terminal">No active wire-tap on this node. Click 'Enable Tap' to stream live tuples across /comms.</pre>
</div>
</div>
</div>
<div id="drawer-pane-json" class="drawer-pane">
<div style="font-size: 11.5px; color: #94a3b8; margin-bottom: 10px;">
Direct two-way synchronization with the visual DAG canvas. You can edit the <code>GraphRule</code> definition below and click <strong>Apply to Canvas</strong>, or deploy directly to the engine.
</div>
<textarea id="studio-json-editor" class="json-sync-textarea" spellcheck="false" placeholder="Generating GraphRule JSON..."></textarea>
<div style="display: flex; gap: 8px; margin-top: 10px; justify-content: flex-end;">
<button class="btn btn-sm" onclick="formatStudioJson()" style="background:#1e293b; color:#cbd5e1; border-color:#334155;">
Format JSON
</button>
<button class="btn btn-sm" onclick="applyJsonToCanvas()" style="background:#1e293b; color:#fb923c; border-color:#ea580c; font-weight:600;">
Apply to Canvas
</button>
<button class="btn btn-sm btn-primary" onclick="deployStudioPipeline()" style="background:#ea580c; border-color:#c2410c; font-weight:600;">
Deploy
</button>
</div>
</div>
</div>
</aside>
</div>
</section>
<section id="view-fleet" style="display: none; flex-direction: column; gap: 24px;">
<section class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">
<span>FLEET GATEWAYS</span>
<span class="badge badge-emerald" id="fleet-kpi-online-badge">0 Online</span>
</div>
<div class="kpi-value tabular" id="fleet-kpi-total">0</div>
<div class="kpi-sub tabular">
<span id="fleet-kpi-online-sub">0 connected Yamux agents</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">
<span>STAGED ROLLOUTS</span>
<span class="badge badge-indigo" id="fleet-kpi-rollouts-badge">Active</span>
</div>
<div class="kpi-value tabular" id="fleet-kpi-rollouts">0</div>
<div class="kpi-sub tabular">
<span id="fleet-kpi-canary-sub">0 Canary evaluating</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">
<span>FLEET IMMUTABLE SNAPSHOTS</span>
</div>
<div class="kpi-value tabular" id="fleet-kpi-snapshots">0</div>
<div class="kpi-sub tabular">
<span>Declarative SHA-256 verified</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">
<span>FLEET DLQ & ROLLBACK ALERTS</span>
<span class="badge badge-slate" id="fleet-kpi-dlq-badge">Clean</span>
</div>
<div class="kpi-value tabular" id="fleet-kpi-dlq">0</div>
<div class="kpi-sub tabular">
<span id="fleet-kpi-rollback-sub">0 auto-rollbacks</span>
</div>
</div>
</section>
<div class="card">
<div class="card-header">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
Staged Rollout Pipeline & Canary Gates
</div>
<div style="display:flex; align-items:center; gap:8px;">
<button class="btn btn-sm btn-primary" onclick="openNewRolloutModal()">
+ New Rollout
</button>
<button class="btn btn-sm" onclick="refreshFleetData(true)">
↻ Refresh
</button>
</div>
</div>
<div style="padding: 20px; border-bottom: 1px solid var(--border); background: var(--bg-canvas);">
<div class="pipeline-container">
<div class="pipeline-step" id="pipe-step-dev">
<div class="pipeline-node" id="pipe-node-dev">1</div>
<div class="pipeline-text">
<div class="pipeline-title">Development</div>
<div class="pipeline-sub">Initial validation</div>
</div>
</div>
<div class="pipeline-connector"></div>
<div class="pipeline-step" id="pipe-step-staging">
<div class="pipeline-node" id="pipe-node-staging">2</div>
<div class="pipeline-text">
<div class="pipeline-title">Staging</div>
<div class="pipeline-sub">Integration testing</div>
</div>
</div>
<div class="pipeline-connector"></div>
<div class="pipeline-step" id="pipe-step-canary">
<div class="pipeline-node" id="pipe-node-canary">3</div>
<div class="pipeline-text">
<div class="pipeline-title">Canary Gate (5%)</div>
<div class="pipeline-sub">Watchdog & DLQ checks</div>
</div>
</div>
<div class="pipeline-connector"></div>
<div class="pipeline-step" id="pipe-step-prod">
<div class="pipeline-node" id="pipe-node-prod">4</div>
<div class="pipeline-text">
<div class="pipeline-title">Production (100%)</div>
<div class="pipeline-sub">Full fleet promotion</div>
</div>
</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Rollout ID</th>
<th>Snapshot ID</th>
<th>Target Cohort / Group</th>
<th>Current Stage</th>
<th>Status</th>
<th>Success / Target</th>
<th>Last Error / Health</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="fleet-rollouts-tbody">
<tr>
<td colspan="8" style="text-align: center; color: var(--text-muted); padding: 24px;">
No active rollouts found. Click "+ New Rollout" to trigger a staged Canary pipeline.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
Edge Device Fleet Catalog
<span class="badge badge-slate tabular" id="fleet-catalog-count-badge">0 devices</span>
</div>
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<input
type="search"
id="fleet-filter-search"
class="text-input"
placeholder="Filter by ID, name, or version..."
oninput="renderFleetDevices()"
style="width: 220px;"
/>
<select id="fleet-filter-plant" class="select-input" onchange="renderFleetDevices()">
<option value="all">All Plants</option>
</select>
<select id="fleet-filter-region" class="select-input" onchange="renderFleetDevices()">
<option value="all">All Regions</option>
</select>
<select id="fleet-filter-status" class="select-input" onchange="renderFleetDevices()">
<option value="all">All Statuses</option>
<option value="online">Online</option>
<option value="degraded">Degraded</option>
<option value="offline">Offline</option>
</select>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Status</th>
<th>Device ID & Name</th>
<th>Plant & Region</th>
<th>Type & Version</th>
<th>CPU / Mem</th>
<th>Active Snapshot</th>
<th>Rules & DLQ</th>
<th>Last Heartbeat</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="fleet-devices-tbody">
<tr>
<td colspan="9" style="text-align: center; color: var(--text-muted); padding: 24px;">
No edge devices currently connected to Control Plane (/fleet/ws).
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="card">
<div class="card-header">
<div class="card-title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
Fleet DLQ & Rollback Alerts
</div>
<span class="badge badge-rose tabular" id="fleet-dlq-count-badge">0 Alerts</span>
</div>
<div class="table-wrap" style="max-height: 280px; overflow-y: auto;">
<table>
<thead>
<tr>
<th>Device ID</th>
<th>DLQ Count</th>
<th>Watchdog Rollback Reason</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody id="fleet-dlq-tbody">
<tr>
<td colspan="4" style="text-align: center; color: var(--text-muted); padding: 18px;">
No edge anomalies or DLQ overflows reported.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Control Plane Audit Trail (kv.rs)
</div>
<button class="btn btn-sm" onclick="refreshFleetData(false)">
↻
</button>
</div>
<div class="table-wrap" style="max-height: 280px; overflow-y: auto;">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>Details</th>
</tr>
</thead>
<tbody id="fleet-audit-tbody">
<tr>
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 18px;">
Audit log is currently empty.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<div id="fleet-inspect-modal" class="fleet-modal-backdrop" style="display: none;">
<div class="fleet-modal-dialog" style="max-width: 840px;">
<div class="fleet-modal-header">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="badge badge-emerald" id="inspect-device-status-badge">Online</span>
<strong id="inspect-modal-title" style="font-size: 15px;">Remote Edge Gateway Inspection</strong>
<span style="font-size: 12px; color: var(--text-muted); font-family: var(--font-mono);" id="inspect-modal-device-id"></span>
</div>
<button class="btn btn-sm" onclick="closeRemoteInspectModal()">✕</button>
</div>
<div class="fleet-modal-body" style="display: flex; flex-direction: column; gap: 14px;">
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--bg-subtle); padding: 10px 14px; border-radius: 6px; flex-wrap: wrap; gap: 8px;">
<div style="display: flex; gap: 16px; font-size: 12px; align-items: center;">
<div>Plant: <strong id="inspect-device-plant">-</strong></div>
<div>Region: <strong id="inspect-device-region">-</strong></div>
<div>Type: <strong id="inspect-device-type">-</strong></div>
<div>Tunnel: <span class="badge badge-emerald" style="padding: 2px 6px;">Yamux Stream 1..N</span></div>
</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<button class="btn btn-sm" onclick="fetchProxyEndpoint('/rules')">/rules</button>
<button class="btn btn-sm" onclick="fetchProxyEndpoint('/streams')">/streams</button>
<button class="btn btn-sm" onclick="fetchProxyEndpoint('/metrics')">/metrics</button>
<button class="btn btn-sm" onclick="fetchProxyEndpoint('/version')">/version</button>
<button class="btn btn-sm" onclick="fetchProxyEndpoint('/')">/ (Sysinfo)</button>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="font-size: 12px; font-weight: 600; color: var(--text-muted);" id="inspect-target-url-label">
Tunneled: GET /api/v1/fleet/devices/:id/proxy/rules
</div>
<button class="btn btn-sm" onclick="refreshCurrentProxyInspection()">↻ Re-query Tunnel</button>
</div>
<pre id="inspect-response-box" style="background: #060911; color: #f8fafc; font-family: var(--font-mono); font-size: 12px; padding: 14px; border-radius: 8px; max-height: 420px; overflow: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-all; border: 1px solid #1e293b;">Connecting to edge gateway over Yamux multiplexed proxy...</pre>
</div>
<div class="fleet-modal-footer">
<button class="btn" onclick="closeRemoteInspectModal()">Close</button>
</div>
</div>
</div>
<div id="fleet-rollout-modal" class="fleet-modal-backdrop" style="display: none;">
<div class="fleet-modal-dialog" style="max-width: 540px;">
<div class="fleet-modal-header">
<strong style="font-size: 15px;">Create Staged Canary Rollout</strong>
<button class="btn btn-sm" onclick="closeNewRolloutModal()">✕</button>
</div>
<div class="fleet-modal-body" style="display: flex; flex-direction: column; gap: 14px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Rollout ID</label>
<input type="text" id="new-rollout-id" class="text-input" style="width: 100%;" placeholder="e.g. rollout-prod-v2-5" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Snapshot ID</label>
<select id="new-rollout-snapshot-id" class="select-input" style="width: 100%;">
<option value="">Select an immutable snapshot...</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Target Plant / Region / Cohort</label>
<input type="text" id="new-rollout-group" class="text-input" style="width: 100%;" placeholder="e.g. plant-detroit or all" value="all" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Initial Target Stage</label>
<select id="new-rollout-stage" class="select-input" style="width: 100%;">
<option value="canary">Canary (5% Gate)</option>
<option value="dev">Dev</option>
<option value="staging">Staging</option>
<option value="production">Production (100%)</option>
</select>
</div>
<div style="font-size: 12px; color: var(--text-muted); background: var(--bg-subtle); padding: 10px; border-radius: 6px;">
ⓘ <strong>Health-Gated Rollout:</strong> If deployed to Canary first, any watchdog rollback or DLQ spike from Canary devices will automatically HALT the rollout and mark it rolled_back.
</div>
</div>
<div class="fleet-modal-footer">
<button class="btn" onclick="closeNewRolloutModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitCreateRollout()">Start Rollout</button>
</div>
</div>
</div>
<div id="rule-designer-modal" class="fleet-modal-overlay" style="display: none; z-index: 105;">
<div class="fleet-modal-dialog" style="max-width: 1120px; width: 95vw; height: 86vh; max-height: 860px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 18px;">🪄</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="rule-designer-modal-title">Guided Rule Pipeline Designer</strong>
<div style="font-size: 11px; color: var(--text-muted);">Step-by-step SQL formulation, multi-sink routing & live engine preflight validation</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeRuleDesignerModal()" title="Close">✕</button>
</div>
<div class="designer-stepper">
<button type="button" class="designer-step-tab active" id="rule-step-tab-1" onclick="goToRuleDesignerStep(1)">
<span class="step-number">1</span>
<span>Source & SQL Query</span>
</button>
<button type="button" class="designer-step-tab" id="rule-step-tab-2" onclick="goToRuleDesignerStep(2)">
<span class="step-number">2</span>
<span>Multi-Sink Outputs</span>
</button>
<button type="button" class="designer-step-tab" id="rule-step-tab-3" onclick="goToRuleDesignerStep(3)">
<span class="step-number">3</span>
<span>Runtime & Resilience</span>
</button>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow: hidden; padding: 16px 20px;">
<div class="designer-split" style="height: 100%;">
<div class="designer-form-col">
<div id="rule-step-view-1" style="display: flex; flex-direction: column; gap: 14px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Pipeline / Rule ID <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="rd-rule-id" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. rule_motor_overheat_alert" oninput="updateRuleDesignerPreview()" />
</div>
<div style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); padding-bottom: 8px;">
<span style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">Query Construction Mode</span>
<div style="display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden;">
<button type="button" id="rd-mode-btn-form" class="btn btn-sm" style="border:none; border-radius:0; background: var(--accent); color: #fff; font-size: 11px;" onclick="setRuleQueryMode('form')">Visual Builder</button>
<button type="button" id="rd-mode-btn-raw" class="btn btn-sm" style="border:none; border-radius:0; background: var(--bg-surface); color: var(--text-secondary); font-size: 11px;" onclick="setRuleQueryMode('raw')">Raw SQL</button>
</div>
</div>
<div style="background: var(--bg-subtle); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px;">
<span style="font-size: 11px; font-weight: 600; color: var(--text-secondary);">⚡ Zero-Knowledge Rule Recipes:</span>
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<button type="button" class="recipe-btn" onclick="loadRuleRecipe('overheat')" title="Alert when temperature exceeds 80°C">🌡 Overheat Alert</button>
<button type="button" class="recipe-btn" onclick="loadRuleRecipe('moving_avg')" title="10-second tumbling moving average">📊 10s Average</button>
<button type="button" class="recipe-btn" onclick="loadRuleRecipe('vibration')" title="Spike detection for machinery">📈 Anomaly Spike</button>
<button type="button" class="recipe-btn" onclick="loadRuleRecipe('dedup')" title="Deduplicate consecutive states">🔄 Deduplicate</button>
</div>
</div>
<div id="rd-visual-panel" style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="font-size: 12px; font-weight: 600;">Source Stream or Table <span style="color:var(--rose-dot)">*</span></label>
<a href="javascript:void(0)" onclick="openDoc('guide/streams/overview')" class="doc-pill-link">📖 Stream Docs</a>
</div>
<select id="rd-source-select" class="select-input" style="width: 100%;" onchange="onRuleSourceChange()">
<option value="">Select source stream/table...</option>
</select>
</div>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="font-size: 12px; font-weight: 600;">Projections (SELECT)</label>
<a href="javascript:void(0)" onclick="openDoc('sqls/query_language_elements#select-clause')" class="doc-pill-link">📖 SELECT Docs</a>
</div>
<input type="text" id="rd-select-fields" class="text-input" style="width: 100%; font-family: var(--font-mono);" value="*" placeholder="* or temperature, humidity, ts" oninput="onVisualSqlFormChange()" />
</div>
</div>
<div id="rd-field-pills-container" style="display: none; padding: 6px 10px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 6px;">
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px;">Click any field to append to SELECT / WHERE:</div>
<div id="rd-field-pills" style="display: flex; flex-wrap: wrap; gap: 6px;"></div>
</div>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="font-size: 12px; font-weight: 600;">WHERE Filter Predicate</label>
<a href="javascript:void(0)" onclick="openDoc('sqls/query_language_elements#where-clause')" class="doc-pill-link">📖 WHERE Docs</a>
</div>
<input type="text" id="rd-where-clause" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. temperature > 65.0 AND status = 'active'" oninput="onVisualSqlFormChange()" />
</div>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; font-weight: 600;">Streaming Windowing</span>
<div style="display: flex; gap: 6px; align-items: center;">
<span style="font-size: 11px; color: var(--text-muted);">Optional temporal aggregation</span>
<a href="javascript:void(0)" onclick="openDoc('concepts/streaming/windowing')" class="doc-pill-link">📖 Windowing Guide</a>
</div>
</div>
<div style="display: grid; grid-template-columns: 1.2fr 0.8fr 1fr; gap: 8px;">
<div>
<select id="rd-window-type" class="select-input" style="width: 100%; font-size: 12px;" onchange="onVisualSqlFormChange(); updateWindowExplanation();">
<option value="none">No Window (Event-by-Event)</option>
<option value="tumbling_time">Tumbling Window (Time)</option>
<option value="tumbling_count">Tumbling Window (Count)</option>
<option value="hopping_time">Hopping / Sliding Window</option>
<option value="session_time">Session Window</option>
</select>
</div>
<div>
<input type="number" id="rd-window-val" class="text-input" style="width: 100%; font-size: 12px;" value="10" placeholder="Duration" oninput="onVisualSqlFormChange()" />
</div>
<div>
<select id="rd-window-unit" class="select-input" style="width: 100%; font-size: 12px;" onchange="onVisualSqlFormChange()">
<option value="ss">Seconds (ss)</option>
<option value="mm">Minutes (mm)</option>
<option value="hh">Hours (hh)</option>
<option value="count">Count (rows)</option>
</select>
</div>
</div>
<div id="rd-window-explanation" style="font-size: 11px; color: var(--text-muted); padding: 4px 8px; background: var(--bg-surface); border-radius: 4px; border: 1px solid var(--border);">
Processes each incoming event immediately as it arrives with zero buffering.
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="font-size: 12px; font-weight: 600;">GROUP BY</label>
<a href="javascript:void(0)" onclick="openDoc('sqls/query_language_elements#group-by-clause')" class="doc-pill-link">📖 GROUP BY Docs</a>
</div>
<input type="text" id="rd-groupby-clause" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. device_id, plant_id" oninput="onVisualSqlFormChange()" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">HAVING</label>
<input type="text" id="rd-having-clause" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. avg(temperature) > 80" oninput="onVisualSqlFormChange()" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 12px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">ORDER BY</label>
<input type="text" id="rd-orderby-clause" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. ts DESC" oninput="onVisualSqlFormChange()" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">LIMIT</label>
<input type="number" id="rd-limit-clause" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. 100" oninput="onVisualSqlFormChange()" />
</div>
</div>
</div>
<div id="rd-raw-panel" style="display: none; flex-direction: column; gap: 8px;">
<div style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center; justify-content: space-between;">
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
<button type="button" class="btn btn-sm" onclick="insertSqlSnippet('SELECT * FROM ')">SELECT *</button>
<button type="button" class="btn btn-sm" onclick="insertSqlSnippet('WHERE temperature > 50.0')">WHERE</button>
<button type="button" class="btn btn-sm" onclick="insertSqlSnippet('GROUP BY TumblingWindow(ss, 10)')">TumblingWindow</button>
<button type="button" class="btn btn-sm" onclick="insertSqlSnippet('GROUP BY HoppingWindow(ss, 10, 5)')">HoppingWindow</button>
<select id="rd-func-insert-select" class="select-input" style="font-size: 11px; padding: 2px 6px;" onchange="insertSqlFunctionSnippet(this.value); this.value='';">
<option value="">+ Insert SQL Function...</option>
<option value="avg">avg(val) — Average</option>
<option value="count">count(*) — Count Rows</option>
<option value="max">max(val) — Maximum</option>
<option value="min">min(val) — Minimum</option>
<option value="abs">abs(val) — Absolute Value</option>
<option value="round">round(val, 2) — Round Decimal</option>
<option value="format_time">format_time(ts, 'YYYY-MM-DD HH:mm:ss')</option>
<option value="json_path_query">json_path_query(col, '$.key')</option>
<option value="celsius_to_fahrenheit">celsius_to_fahrenheit(c)</option>
</select>
</div>
<a href="javascript:void(0)" onclick="openDoc('sqls/functions/overview')" class="doc-pill-link">📖 221+ Functions Reference</a>
</div>
<textarea id="rd-raw-sql" class="text-input" style="width: 100%; height: 260px; font-family: var(--font-mono); font-size: 13px; line-height: 1.5; resize: vertical;" placeholder="SELECT * FROM demo WHERE temperature > 50.0" oninput="onRawSqlChange()"></textarea>
</div>
</div>
<div id="rule-step-view-2" style="display: none; flex-direction: column; gap: 14px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong style="font-size: 13px;">Egress Sinks & Delivery Routing</strong>
<div style="font-size: 11px; color: var(--text-muted);">Route pipeline events to one or more edge/cloud destinations</div>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="addRuleDesignerSink()">+ Add Sink</button>
</div>
<div id="rd-sinks-container" style="display: flex; flex-direction: column; gap: 12px; overflow-y: auto;">
</div>
</div>
<div id="rule-step-view-3" style="display: none; flex-direction: column; gap: 14px;">
<div>
<strong style="font-size: 13px;">Execution Runtime & QoS Guarantees</strong>
<div style="font-size: 11px; color: var(--text-muted);">Fine-tune concurrency, checkpointing, event-time semantics, and error handling</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Quality of Service (QoS)</label>
<select id="rd-opt-qos" class="select-input" style="width: 100%;" onchange="updateRuleDesignerPreview()">
<option value="0">0 — At Most Once (Fastest, zero state replay)</option>
<option value="1">1 — At Least Once (Acknowledged delivery)</option>
<option value="2">2 — Exactly Once (State snapshot checkpointing)</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Checkpoint Interval (ms)</label>
<input type="number" id="rd-opt-checkpoint" class="text-input" style="width: 100%;" value="300000" oninput="updateRuleDesignerPreview()" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Operator Concurrency</label>
<input type="number" id="rd-opt-concurrency" class="text-input" style="width: 100%;" value="1" min="1" max="64" oninput="updateRuleDesignerPreview()" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Ring Buffer Length</label>
<input type="number" id="rd-opt-buffer" class="text-input" style="width: 100%;" value="1024" oninput="updateRuleDesignerPreview()" />
</div>
</div>
<div style="background: var(--bg-subtle); padding: 12px; border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px;">
<label style="display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; cursor: pointer;">
<input type="checkbox" id="rd-opt-is-event-time" onchange="toggleEventTimeFields(); updateRuleDesignerPreview();" />
<span>Use Event-Time Semantics (extract timestamp from payload)</span>
</label>
<div id="rd-event-time-fields" style="display: none; grid-template-columns: 1.2fr 0.8fr; gap: 10px;">
<div>
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 3px;">Timestamp Field</label>
<input type="text" id="rd-opt-ts-field" class="text-input" style="width: 100%; font-size: 12px;" placeholder="e.g. ts or timestamp" value="ts" oninput="updateRuleDesignerPreview()" />
</div>
<div>
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 3px;">Late Tolerance (ms)</label>
<input type="number" id="rd-opt-late-tolerance" class="text-input" style="width: 100%; font-size: 12px;" value="1000" oninput="updateRuleDesignerPreview()" />
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<label style="display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer;">
<input type="checkbox" id="rd-opt-send-meta" onchange="updateRuleDesignerPreview()" />
<span><strong>Attach Metadata (__meta__)</strong> to sink payloads (topic, client_id, timestamp)</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer;">
<input type="checkbox" id="rd-opt-send-error" onchange="updateRuleDesignerPreview()" />
<span><strong>Forward Runtime Errors to Sinks</strong> (routes codec/SQL eval failures down the graph)</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer;">
<input type="checkbox" id="rd-opt-triggered" onchange="updateRuleDesignerPreview()" />
<span><strong>Deploy in Paused State</strong> (rule is created but not started until manually triggered)</span>
</label>
</div>
</div>
</div>
<div class="designer-preview-col">
<div class="designer-preview-header">
<span style="font-weight: 600; display: flex; align-items: center; gap: 6px;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Pipeline JSON Preview
</span>
<div style="display: flex; align-items: center; gap: 8px;">
<label style="display: flex; align-items: center; gap: 4px; font-size: 11px; cursor: pointer; color: #94a3b8;">
<input type="checkbox" id="rd-mask-secrets" checked onchange="updateRuleDesignerPreview()" />
<span>Mask Secrets</span>
</label>
<button type="button" class="btn btn-sm" style="font-size: 11px; padding: 2px 8px; background: #1e293b; color: #94a3b8; border-color: #334155;" onclick="copyRuleDesignerJson()">Copy</button>
</div>
</div>
<pre id="rd-preview-json" class="designer-preview-body" style="margin: 0;"></pre>
<div class="designer-preview-footer">
<button type="button" id="rd-validate-btn" class="btn btn-sm" style="background: #1e293b; color: #38bdf8; border-color: #0284c7; width: 100%; font-weight: 600;" onclick="validateRuleDesignerWithEngine()">
✔ Validate with Engine Preflight
</button>
<div id="rd-validation-msg" style="font-size: 11px; display: none;"></div>
</div>
</div>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; display: flex; justify-content: space-between; align-items: center;">
<button type="button" class="btn btn-sm" onclick="closeRuleDesignerModal()">Cancel</button>
<div style="display: flex; gap: 8px;">
<button type="button" id="rd-prev-btn" class="btn btn-sm" onclick="stepRuleDesigner(-1)" style="display: none;">← Back</button>
<button type="button" id="rd-next-btn" class="btn btn-sm btn-primary" onclick="stepRuleDesigner(1)">Next Step →</button>
<button type="button" id="rd-deploy-btn" class="btn btn-sm btn-primary" onclick="submitRuleDesignerDeployment()" style="background: #16a34a; border-color: #15803d; display: none;">
Deploy Rule Pipeline 🚀
</button>
</div>
</div>
</div>
</div>
<div id="stream-designer-modal" class="fleet-modal-overlay" style="display: none; z-index: 105;">
<div class="fleet-modal-dialog" style="max-width: 920px; width: 90vw; height: 85vh; max-height: 800px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">📋</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="stream-designer-modal-title">Visual Schema & Topology Builder</strong>
<div style="font-size: 11px; color: var(--text-muted);">Define streams and lookup tables with strongly-typed field schemas or schemaless ingestion</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeStreamDesignerModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow-y: auto; padding: 18px 20px; display: flex; flex-direction: column; gap: 16px;">
<div style="background: var(--bg-subtle); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 11px; font-weight: 600; color: var(--text-secondary);">⚡ Zero-Knowledge IoT Templates:</span>
<select id="sd-template-select" class="select-input" style="font-size: 11px; padding: 3px 8px;" onchange="loadStreamTemplate(this.value)">
<option value="">-- Choose Template Preset --</option>
<option value="machinery">Industrial Machinery (Temp, RPM, Vibration, Status)</option>
<option value="energy">Power & Energy Meter (Voltage, Current, Power, kWh)</option>
<option value="vehicle">Fleet GPS Tracker (Lat, Long, Speed, Battery)</option>
<option value="air_quality">Air Quality (PM2.5, PM10, CO2, Humidity)</option>
</select>
</div>
<div style="display: flex; gap: 6px;">
<a href="javascript:void(0)" onclick="openDoc('guide/sources/overview')" class="doc-pill-link">📖 Connectors</a>
<a href="javascript:void(0)" onclick="openDoc('guide/serialization/serialization')" class="doc-pill-link">📖 Formats</a>
<a href="javascript:void(0)" onclick="openDoc('sqls/data_types')" class="doc-pill-link">📖 Data Types</a>
</div>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--bg-subtle); padding: 10px 14px; border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 12px; font-weight: 600;">Catalog Topology Kind:</span>
<div style="display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden;">
<button type="button" id="sd-kind-stream" class="btn btn-sm" style="border:none; border-radius:0; background: var(--accent); color: #fff; font-size: 12px;" onclick="setStreamDesignerKind('stream')">Stream (Real-Time Ingestion)</button>
<button type="button" id="sd-kind-table" class="btn btn-sm" style="border:none; border-radius:0; background: var(--bg-surface); color: var(--text-secondary); font-size: 12px;" onclick="setStreamDesignerKind('table')">Table (Lookup / Dimension Scan)</button>
</div>
</div>
<label style="display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer;">
<input type="checkbox" id="sd-schemaless-toggle" onchange="toggleSchemalessMode()" />
<span style="font-weight: 600;">Schemaless (Dynamic Ingestion)</span>
</label>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 14px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;" id="sd-name-label">Stream Name <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="sd-name" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. factory_telemetry" oninput="updateStreamDesignerDdl()" />
</div>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="font-size: 12px; font-weight: 600;">Connector Type <span style="color:var(--rose-dot)">*</span></label>
<a href="javascript:void(0)" onclick="openDoc('guide/sources/overview')" class="doc-pill-link">📖 All Connectors</a>
</div>
<select id="sd-connector-type" class="select-input" style="width: 100%;" onchange="onStreamConnectorChange()">
<option value="mqtt">MQTT (v3.1.1 / v5.0 Broker)</option>
<option value="http">HTTP / REST Pull Source</option>
<option value="kafka">Apache Kafka Broker</option>
<option value="file">File / CSV / Parquet Stream</option>
<option value="memory">In-Memory Bus (rekuiper internal)</option>
<option value="edgex">EdgeX Foundry MessageBus</option>
<option value="modbus">Modbus TCP / RTU PLC</option>
<option value="opcua">OPC-UA Industrial Server</option>
<option value="simulator">Synthetic Simulator</option>
<option value="neuron">Neuron IIoT Gateway</option>
</select>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 14px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Datasource / Topic <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="sd-datasource" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. factory/+/sensors or /data" value="factory/+/sensors" oninput="updateStreamDesignerDdl()" />
</div>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label style="font-size: 12px; font-weight: 600;">Stream Payload Format</label>
<a href="javascript:void(0)" onclick="openDoc('guide/serialization/serialization')" class="doc-pill-link">📖 Format Specs</a>
</div>
<select id="sd-format" class="select-input" style="width: 100%;" onchange="onStreamFormatChange()">
<option value="json">JSON (Standard Object / Array)</option>
<option value="binary">Binary (Raw byte array)</option>
<option value="delimited">Delimited / CSV</option>
<option value="protobuf">Protobuf</option>
<option value="custom">Custom Extensible Format</option>
</select>
</div>
</div>
<div id="sd-schema-options-row" style="display: none; background: var(--bg-subtle); padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border);">
<div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: flex-end;">
<div>
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px;">Registered Schema ID <span style="color:var(--rose-dot)">*</span></label>
<select id="sd-schema-select" class="select-input" style="width: 100%; font-family: var(--font-mono);" onchange="onStreamSchemaSelectChange()">
<option value="">-- Select Schema --</option>
</select>
</div>
<div id="sd-msg-type-wrapper">
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px;">Proto3 Message Type <span style="color:var(--rose-dot)">*</span></label>
<select id="sd-message-type-select" class="select-input" style="width: 100%; font-family: var(--font-mono);" onchange="updateStreamDesignerDdl()">
<option value="">-- Select Message Type --</option>
</select>
</div>
<div style="display: flex; gap: 6px;">
<button type="button" class="btn btn-sm" onclick="openNewSchemaModal()" title="Create New Schema">
+ New Schema
</button>
<button type="button" id="sd-autofill-btn" class="btn btn-sm btn-primary" onclick="autofillStreamFieldsFromSchema()" title="Autofill typed stream fields from the selected Protobuf message definition" style="display: none;">
Autofill Fields 🪄
</button>
</div>
</div>
</div>
<div id="sd-delimited-options-row" style="display: none; background: var(--bg-subtle); padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border);">
<div style="display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: flex-end;">
<div>
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px;">Delimiter Char</label>
<input type="text" id="sd-delimiter-input" class="text-input" style="width: 100%; font-family: var(--font-mono);" value="," maxlength="4" oninput="updateStreamDesignerDdl()" />
</div>
<div style="font-size: 11px; color: var(--text-muted); padding-bottom: 6px;">
Separator for CSV / TSV / custom delimited stream ingestion (e.g. <code>,</code>, <code>;</code>, <code>\t</code>).
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr auto auto; gap: 10px; align-items: flex-end;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Configuration Key (ConfKey)</label>
<select id="sd-confkey-select" class="select-input" style="width: 100%;" onchange="updateStreamDesignerDdl()">
<option value="">Default (Global / inline defaults)</option>
</select>
</div>
<button type="button" class="btn btn-sm" onclick="openInlineConfKeyModal(document.getElementById('sd-connector-type').value)" style="white-space: nowrap;">
+ New ConfKey
</button>
<button type="button" class="btn btn-sm" onclick="openStreamAdvancedProperties()" style="white-space: nowrap;" title="Configure connector advanced properties">
⚙ Advanced Properties
</button>
</div>
<div id="sd-fields-section" style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong style="font-size: 12px;">Schema Fields Definition</strong>
<span style="font-size: 11px; color: var(--text-muted); margin-left: 6px;">Define typed attributes for validation and optimized query evaluation</span>
</div>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="toggleInferJsonBox()" style="background: #eff6ff; border-color: #bfdbfe; color: #1e40af; font-weight: 600;">
✨ Infer Schema from Sample JSON
</button>
<button type="button" class="btn btn-sm btn-primary" onclick="addStreamFieldRow()">+ Add Field</button>
</div>
</div>
<div id="sd-infer-json-box" style="display: none; background: #fafbfc; border: 1px solid #bfdbfe; border-radius: 6px; padding: 12px; flex-direction: column; gap: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; font-weight: 600; color: #1e40af;">Paste Sample JSON Message to Infer Schema:</span>
<span style="font-size: 11px; color: var(--text-muted);">Attributes and data types will be automatically populated</span>
</div>
<textarea id="sd-sample-json-input" class="text-input" style="width: 100%; height: 75px; font-family: var(--font-mono); font-size: 12px;" placeholder='{"device_id": "sensor_01", "temperature": 75.4, "pressure": 101.3, "active": true}'></textarea>
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="toggleInferJsonBox()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="inferStreamFieldsFromJson()">Infer & Populate Fields 🪄</button>
</div>
</div>
<div style="border: 1px solid var(--border); border-radius: 6px; overflow: hidden;">
<table class="fields-grid-table">
<thead>
<tr>
<th style="width: 35%;">Field Name</th>
<th style="width: 35%;">Data Type</th>
<th style="width: 20%;">Optional / Nullable</th>
<th style="width: 10%; text-align: center;">Action</th>
</tr>
</thead>
<tbody id="sd-fields-tbody">
</tbody>
</table>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">Generated DDL SQL Preview</span>
<button type="button" class="btn btn-sm" style="font-size: 11px; padding: 2px 8px;" onclick="copyStreamDesignerDdl()">Copy SQL</button>
</div>
<pre id="sd-ddl-preview" style="background: #090e17; border: 1px solid #1e293b; color: #38bdf8; font-family: var(--font-mono); font-size: 12px; padding: 12px 14px; border-radius: 6px; margin: 0; line-height: 1.5; white-space: pre-wrap; overflow-x: auto;"></pre>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; display: flex; justify-content: space-between; align-items: center;">
<button type="button" class="btn btn-sm" onclick="closeStreamDesignerModal()">Cancel</button>
<button type="button" id="sd-submit-btn" class="btn btn-sm btn-primary" onclick="submitStreamDesigner()">
Create Stream
</button>
</div>
</div>
</div>
<div id="inline-confkey-modal" class="fleet-modal-overlay" style="display: none; z-index: 110;">
<div class="fleet-modal-dialog" style="max-width: 480px;">
<div class="fleet-modal-header">
<strong style="font-size: 15px;">New Connector Configuration Key</strong>
<button class="btn btn-sm" onclick="closeInlineConfKeyModal()">✕</button>
</div>
<div class="fleet-modal-body" style="display: flex; flex-direction: column; gap: 14px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Connector Type</label>
<input type="text" id="inline-ck-type" class="text-input" style="width: 100%;" readonly />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Configuration Key Name <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="inline-ck-name" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. factory_broker" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Configuration Properties (JSON) <span style="color:var(--rose-dot)">*</span></label>
<textarea id="inline-ck-json" class="text-input" style="width: 100%; height: 160px; font-family: var(--font-mono); font-size: 12px; resize: vertical;" placeholder='{\n "server": "tcp://127.0.0.1:1883",\n "username": "admin",\n "password": "secret"\n}'></textarea>
</div>
</div>
<div class="fleet-modal-footer">
<button type="button" class="btn btn-sm" onclick="closeInlineConfKeyModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitInlineConfKey()">Save & Apply</button>
</div>
</div>
</div>
<div id="connection-modal" class="fleet-modal-overlay" style="display: none; z-index: 106;">
<div class="fleet-modal-dialog" style="max-width: 860px; width: 92vw; height: 84vh; max-height: 760px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 18px;">🔌</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="conn-modal-title">New Shared Connection</strong>
<div style="font-size: 11px; color: var(--text-muted);">Manage pooled connector endpoints, credentials & socket parameters</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeConnectionModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow-y: auto; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px;">
<div style="display: grid; grid-template-columns: 1.2fr 1fr; gap: 14px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Connection ID <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="conn-modal-id" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. factory_mqtt_broker" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Connector Type <span style="color:var(--rose-dot)">*</span></label>
<select id="conn-modal-type" class="select-input" style="width: 100%;" onchange="onConnectionTypeChange()">
<option value="mqtt">MQTT Broker (v3.1.1 / v5.0)</option>
<option value="edgex">EdgeX Foundry MessageBus</option>
<option value="kafka">Apache Kafka Cluster</option>
<option value="http">HTTP / REST Service</option>
<option value="sql">SQL Relational Database</option>
<option value="modbus">Modbus TCP / RTU PLC</option>
<option value="opcua">OPC-UA Server</option>
<option value="redis">Redis Server</option>
<option value="zmq">ZeroMQ Socket</option>
<option value="neuron">Neuron Industrial Gateway</option>
</select>
</div>
</div>
<div class="prop-dual-toggle-bar">
<span style="font-size: 12px; font-weight: 600; color: var(--text-secondary);" id="conn-props-header-label">
Connection Properties (Schema-Validated)
</span>
<div style="display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden;">
<button type="button" id="conn-btn-mode-form" class="btn btn-sm" style="border:none; border-radius:0; background: var(--accent); color: #fff; font-size: 11px;" onclick="setConnectionEditMode('form')">Visual Form</button>
<button type="button" id="conn-btn-mode-json" class="btn btn-sm" style="border:none; border-radius:0; background: var(--bg-surface); color: var(--text-secondary); font-size: 11px;" onclick="setConnectionEditMode('json')">Raw JSON</button>
</div>
</div>
<div id="conn-form-container" style="display: block;">
<div id="conn-properties-target"></div>
</div>
<div id="conn-json-container" style="display: none; flex-direction: column; gap: 8px;">
<textarea id="conn-raw-json" class="text-input" style="width: 100%; height: 240px; font-family: var(--font-mono); font-size: 12px; line-height: 1.4; resize: vertical;" oninput="onConnectionRawJsonInput()"></textarea>
<div id="conn-json-error" style="display: none; color: var(--rose-text); font-size: 11px; font-family: var(--font-mono); background: #fff1f2; padding: 6px 10px; border-radius: 4px; border: 1px solid #fecdd3;"></div>
</div>
<div id="conn-test-banner" style="display: none; padding: 10px 14px; border-radius: 6px; font-size: 12px; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 8px;" id="conn-test-msg"></div>
<button class="btn btn-xs" onclick="document.getElementById('conn-test-banner').style.display='none'">Dismiss</button>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="testConnectionPreflightFromModal()" id="conn-test-btn">
<span>⚡ Test Preflight</span>
</button>
</div>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closeConnectionModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitConnectionModal()" id="conn-save-btn">
Save Connection
</button>
</div>
</div>
</div>
</div>
<div id="confkeys-manager-modal" class="fleet-modal-overlay" style="display: none; z-index: 106;">
<div class="fleet-modal-dialog" style="max-width: 980px; width: 94vw; height: 86vh; max-height: 800px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 18px;">🔑</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);">Connector Configuration Keys Hub</strong>
<div style="font-size: 11px; color: var(--text-muted);">Manage and test reusable configuration key profiles across sources & sinks</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeConfKeysManagerModal()" title="Close">✕</button>
</div>
<div style="padding: 10px 20px; background: var(--bg-subtle); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px;">
<div style="display: flex; align-items: center; gap: 10px;">
<label style="font-size: 12px; font-weight: 600;">Connector:</label>
<select id="ck-connector-select" class="select-input" style="font-size: 12px; font-weight: 600; padding: 4px 10px;" onchange="onConfKeysConnectorChange(this.value)">
<option value="mqtt">MQTT (v3.1.1 / v5.0)</option>
<option value="http">HTTP / REST</option>
<option value="kafka">Apache Kafka</option>
<option value="file">File Stream</option>
<option value="modbus">Modbus TCP/RTU</option>
<option value="opcua">OPC-UA</option>
<option value="edgex">EdgeX Foundry</option>
<option value="neuron">Neuron Gateway</option>
<option value="redis">Redis</option>
<option value="sql">SQL Database</option>
<option value="zmq">ZeroMQ</option>
</select>
</div>
<div style="font-size: 11px; color: var(--text-muted);">
Endpoints: <code style="font-family:var(--font-mono); font-size: 11px;">/metadata/sources/{type}/confKeys</code> & <code style="font-family:var(--font-mono); font-size: 11px;">/configs/sources/{type}</code>
</div>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow: hidden; padding: 16px 20px;">
<div class="confkeys-master-detail">
<div class="confkeys-sidebar">
<div style="display: flex; gap: 6px;">
<input type="text" id="ck-search-input" class="text-input" style="flex: 1; font-size: 11px; padding: 4px 8px;" placeholder="Filter keys..." oninput="filterConfKeysList()" />
<button type="button" class="btn btn-sm btn-primary" style="font-size: 11px; padding: 4px 8px;" onclick="createNewConfKey()" title="Add new configuration key">+ New</button>
</div>
<div class="confkeys-list" id="ck-items-list">
<div class="empty-state" style="padding: 20px 8px; font-size: 11px;">No confKeys found.</div>
</div>
</div>
<div style="flex: 1; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-left: 6px;">
<div style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); padding-bottom: 10px;">
<div style="flex: 1; margin-right: 12px;">
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 3px;">Configuration Key Name <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="ck-key-name" class="text-input" style="width: 100%; font-family: var(--font-mono); font-size: 12px;" placeholder="e.g. factory_broker" />
</div>
<div style="display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-top: 14px;">
<button type="button" id="ck-btn-mode-form" class="btn btn-sm" style="border:none; border-radius:0; background: var(--accent); color: #fff; font-size: 11px;" onclick="setConfKeyEditMode('form')">Visual Form</button>
<button type="button" id="ck-btn-mode-json" class="btn btn-sm" style="border:none; border-radius:0; background: var(--bg-surface); color: var(--text-secondary); font-size: 11px;" onclick="setConfKeyEditMode('json')">Raw JSON</button>
</div>
</div>
<div id="ck-form-container">
<div id="ck-properties-target"></div>
</div>
<div id="ck-json-container" style="display: none; flex-direction: column; gap: 8px;">
<textarea id="ck-raw-json" class="text-input" style="width: 100%; height: 260px; font-family: var(--font-mono); font-size: 12px; resize: vertical;" oninput="onConfKeyRawJsonInput()"></textarea>
<div id="ck-json-error" style="display: none; color: var(--rose-text); font-size: 11px; font-family: var(--font-mono); background: #fff1f2; padding: 6px 10px; border-radius: 4px; border: 1px solid #fecdd3;"></div>
</div>
<div id="ck-test-banner" style="display: none; padding: 8px 12px; border-radius: 6px; font-size: 12px; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 8px;" id="ck-test-msg"></div>
<button class="btn btn-xs" onclick="document.getElementById('ck-test-banner').style.display='none'">Dismiss</button>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--border);">
<button type="button" class="btn btn-sm" onclick="testConfKeyPreflight()">
<span>⚡ Test Preflight</span>
</button>
<div style="display: flex; gap: 8px;">
<button type="button" id="ck-delete-btn" class="btn btn-sm" style="color:var(--rose-text);" onclick="deleteSelectedConfKey()">Delete Key</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitCurrentConfKey()">Save ConfKey 💾</button>
</div>
</div>
</div>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 10px 20px;">
<button type="button" class="btn btn-sm" onclick="closeConfKeysManagerModal()">Close</button>
</div>
</div>
</div>
<div id="dynamic-properties-modal" class="fleet-modal-overlay" style="display: none; z-index: 108;">
<div class="fleet-modal-dialog" style="max-width: 800px; width: 90vw; height: 78vh; max-height: 720px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">⚙</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="dyn-modal-title">Connector Advanced Properties</strong>
<div style="font-size: 11px; color: var(--text-muted);" id="dyn-modal-subtitle">Fine-tune protocol parameters, TLS security & buffers</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeDynamicPropertiesModal()" title="Close">✕</button>
</div>
<div style="padding: 8px 20px; background: var(--bg-subtle); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 12px; font-weight: 600;" id="dyn-connector-badge">Connector: MQTT</span>
<div style="display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden;">
<button type="button" id="dyn-btn-mode-form" class="btn btn-sm" style="border:none; border-radius:0; background: var(--accent); color: #fff; font-size: 11px;" onclick="setDynModalMode('form')">Visual Form</button>
<button type="button" id="dyn-btn-mode-json" class="btn btn-sm" style="border:none; border-radius:0; background: var(--bg-surface); color: var(--text-secondary); font-size: 11px;" onclick="setDynModalMode('json')">Raw JSON</button>
</div>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow-y: auto; padding: 18px 20px;">
<div id="dyn-form-container">
<div id="dyn-properties-target"></div>
</div>
<div id="dyn-json-container" style="display: none; flex-direction: column; gap: 8px;">
<textarea id="dyn-raw-json" class="text-input" style="width: 100%; height: 280px; font-family: var(--font-mono); font-size: 12px; resize: vertical;" oninput="onDynRawJsonInput()"></textarea>
<div id="dyn-json-error" style="display: none; color: var(--rose-text); font-size: 11px; font-family: var(--font-mono); background: #fff1f2; padding: 6px 10px; border-radius: 4px; border: 1px solid #fecdd3;"></div>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; display: flex; justify-content: flex-end; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closeDynamicPropertiesModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="applyDynamicPropertiesModal()">Apply Properties</button>
</div>
</div>
</div>
<div id="schema-editor-modal" class="fleet-modal-overlay" style="display: none; z-index: 107;">
<div class="fleet-modal-dialog" style="max-width: 980px; width: 94vw; height: 86vh; max-height: 820px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 20px;">📜</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="schema-modal-title">Schema Registry & Serialization Hub</strong>
<div style="font-size: 11px; color: var(--text-muted);">Manage Proto3 contracts, custom binary decoders, and message serialization schemas</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeSchemaModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow-y: auto; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px;">
<div style="display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: 14px; align-items: flex-end;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Schema Name / ID <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="schema-modal-name" class="text-input" style="width: 100%; font-family: var(--font-mono);" placeholder="e.g. factory_telemetry" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Serialization Format <span style="color:var(--rose-dot)">*</span></label>
<select id="schema-modal-kind" class="select-input" style="width: 100%; font-weight: 600;" onchange="onSchemaModalKindChange()">
<option value="protobuf">Protobuf 3 (Google Protocol Buffers)</option>
<option value="custom">Custom Binary Codec / Shared Lib</option>
<option value="json">JSON Schema (Draft-07 Object)</option>
<option value="delimited">Delimited / CSV Field Spec</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Load Snippet Template</label>
<select id="schema-template-select" class="select-input" style="width: 100%;" onchange="loadSchemaTemplate(this.value)">
<option value="">-- Select Template --</option>
<option value="proto_sensor">Protobuf: IIoT Sensor Telemetry</option>
<option value="proto_alarm">Protobuf: Machine State & Alarms</option>
<option value="proto_opcua">Protobuf: OPC-UA Tag Bundle</option>
<option value="custom_modbus">Custom: Modbus RTU Register Map</option>
<option value="json_metric">JSON Schema: IIoT Metric Event</option>
<option value="delimited_csv">Delimited: CSV Machine Log Spec</option>
</select>
</div>
</div>
<div style="display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 14px; flex: 1; min-height: 380px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; font-weight: 600; color: var(--text-secondary);" id="schema-editor-label">Schema Definition (.proto / code)</span>
<div style="display: flex; gap: 6px;">
<input type="file" id="schema-file-upload-input" style="display: none;" accept=".proto,.json,.txt" onchange="handleSchemaFileUpload(event)" />
<button type="button" class="btn btn-sm" onclick="document.getElementById('schema-file-upload-input').click()" title="Upload file from disk">
📁 Upload File
</button>
<button type="button" class="btn btn-sm" onclick="formatSchemaContent()" title="Format Code">
🪄 Format
</button>
</div>
</div>
<textarea id="schema-modal-content" class="text-input" style="flex: 1; width: 100%; min-height: 360px; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; background: #090e17; color: #38bdf8; border: 1px solid #1e293b; border-radius: 6px; padding: 12px 14px; tab-size: 2; resize: none; white-space: pre;" placeholder="Enter schema definition..." oninput="onSchemaContentInput()"></textarea>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 8px;">
<span style="font-size: 12px; font-weight: 700; color: var(--text-primary);">Contract Symbols & AST</span>
<button type="button" class="btn btn-sm" onclick="validateSchemaModalSyntax()" style="font-size: 11px; padding: 2px 8px;">
Validate Syntax ⚡
</button>
</div>
<div id="schema-ast-status-box" style="padding: 8px 12px; border-radius: 6px; background: var(--bg-subtle); border: 1px solid var(--border); font-size: 11px;">
<span id="schema-ast-status-text" style="color: var(--text-muted);">Ready to validate</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div>
<span style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">Syntax Standard</span>
<div id="schema-ast-syntax" style="font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: #8b5cf6;">proto3</div>
</div>
<div>
<span style="font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">Package Identifier</span>
<div id="schema-ast-package" style="font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--text-secondary);">(none)</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 11px; font-weight: 700; color: var(--text-secondary);">Extracted Messages (<span id="schema-ast-msg-count">0</span>)</span>
<div id="schema-ast-messages-container" style="display: flex; flex-direction: column; gap: 8px;">
<div style="font-size: 11px; color: var(--text-muted);">No messages detected yet.</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px;" id="schema-ast-enums-wrapper">
<span style="font-size: 11px; font-weight: 700; color: var(--text-secondary);">Enums (<span id="schema-ast-enum-count">0</span>)</span>
<div id="schema-ast-enums-container" style="display: flex; flex-wrap: wrap; gap: 4px;">
<div style="font-size: 11px; color: var(--text-muted);">No enums defined.</div>
</div>
</div>
</div>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; background: #fafbfc; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
<div id="schema-modal-footer-msg" style="font-size: 12px; color: var(--text-muted);">Single-binary zero-dependency serialization hub</div>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closeSchemaModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitSchemaModal()">Save & Register Schema 💾</button>
</div>
</div>
</div>
</div>
<div id="schema-inspector-modal" class="fleet-modal-overlay" style="display: none; z-index: 108;">
<div class="fleet-modal-dialog" style="max-width: 820px; width: 90vw; height: 78vh; max-height: 720px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">🔍</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="schema-inspect-title">Schema Inspector</strong>
<div style="font-size: 11px; color: var(--text-muted);" id="schema-inspect-subtitle">Contract details, parsed types & pipeline references</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeSchemaInspectorModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow-y: auto; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px;">
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--bg-subtle); padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border); flex-wrap: wrap; gap: 8px;">
<div style="display: flex; align-items: center; gap: 12px;">
<span id="schema-inspect-kind-badge" class="badge badge-slate" style="text-transform: uppercase; font-weight: 700;">PROTOBUF</span>
<span id="schema-inspect-ref-badge" class="badge badge-emerald">0 Active References</span>
</div>
<div style="display: flex; gap: 6px;">
<button type="button" class="btn btn-sm" onclick="copySchemaInspectContent()">Copy Content 📋</button>
<button type="button" class="btn btn-sm btn-primary" id="schema-inspect-edit-btn" onclick="editFromSchemaInspector()">Edit Schema ✏</button>
</div>
</div>
<div id="schema-inspect-types-box" style="display: flex; flex-direction: column; gap: 6px;">
<span style="font-size: 11px; font-weight: 700; color: var(--text-secondary);">Declared Message Types / Symbols</span>
<div id="schema-inspect-types-list" style="display: flex; flex-wrap: wrap; gap: 6px;"></div>
</div>
<div style="display: flex; flex-direction: column; gap: 6px; flex: 1;">
<span style="font-size: 11px; font-weight: 700; color: var(--text-secondary);">Raw Schema Definition</span>
<pre id="schema-inspect-code" style="flex: 1; min-height: 240px; background: #090e17; color: #38bdf8; font-family: var(--font-mono); font-size: 12px; padding: 12px 14px; border-radius: 6px; border: 1px solid #1e293b; margin: 0; line-height: 1.5; white-space: pre-wrap; overflow-x: auto;"></pre>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; display: flex; justify-content: flex-end;">
<button type="button" class="btn btn-sm" onclick="closeSchemaInspectorModal()">Close</button>
</div>
</div>
</div>
<div id="plugin-install-modal" class="fleet-modal-overlay" style="display: none; z-index: 110;">
<div class="fleet-modal-dialog" style="max-width: 600px; width: 90vw;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">🔌</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);">Register Plugin / Codec</strong>
<div style="font-size: 11px; color: var(--text-muted);">Install Wasm, native C/Rust, or portable plugins</div>
</div>
</div>
<button class="btn btn-sm" onclick="closePluginInstallModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="padding: 20px; display: flex; flex-direction: column; gap: 14px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Plugin Type <span style="color:var(--rose-dot)">*</span></label>
<select id="plugin-modal-type" class="select-input" style="width: 100%;">
<option value="wasm">WebAssembly (Wasm) 🦀</option>
<option value="source">Source Connector 📥</option>
<option value="sink">Sink Connector 📤</option>
<option value="function">Function Plugin ⚡</option>
<option value="portable">Portable Plugin 📦</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Plugin Name <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="plugin-modal-name" class="text-input" placeholder="e.g. wasm_filter" style="width: 100%; font-family: var(--font-mono);" />
</div>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">File Path or Remote URL <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="plugin-modal-file" class="text-input" placeholder="file:///etc/uploads/plugin.wasm or https://..." style="width: 100%; font-family: var(--font-mono);" />
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Can reference an artifact from File Storage (e.g. etc/uploads/my_plugin.wasm)</div>
</div>
<div id="plugin-modal-symbols-row">
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Exported Functions / Symbols (optional)</label>
<input type="text" id="plugin-modal-symbols" class="text-input" placeholder="Comma-separated: filter, transform, score" style="width: 100%; font-family: var(--font-mono);" />
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; background: #fafbfc; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closePluginInstallModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitPluginInstall()">Register & Install 🔌</button>
</div>
</div>
</div>
<div id="js-udf-modal" class="fleet-modal-overlay" style="display: none; z-index: 110;">
<div class="fleet-modal-dialog" style="max-width: 960px; width: 92vw; height: 86vh; max-height: 800px; display: flex; flex-direction: column;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">⚡</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="js-udf-modal-title">JavaScript User-Defined Function (UDF)</strong>
<div style="font-size: 11px; color: var(--text-muted);">Author scalar transformations and custom aggregate functions in JavaScript</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeJsUdfModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="flex: 1; overflow-y: auto; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Function Name / Identifier <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="js-udf-modal-id" class="text-input" placeholder="e.g. celsius_to_fahrenheit" style="width: 100%; font-family: var(--font-mono);" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Function Mode <span style="color:var(--rose-dot)">*</span></label>
<select id="js-udf-modal-agg" class="select-input" style="width: 100%;" onchange="handleJsUdfKindChange()">
<option value="false">Scalar Function (Row-by-Row 1 → 1)</option>
<option value="true">Window Aggregate Function (Array → 1)</option>
</select>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Code Snippet Templates</label>
<select id="js-udf-modal-template" class="select-input" style="width: 100%;" onchange="loadJsUdfTemplate(this.value)">
<option value="">Load Template...</option>
<option value="scalar_temp">Temperature Converter (Scalar)</option>
<option value="scalar_norm">Min-Max Normalizer (Scalar)</option>
<option value="agg_median">Moving Median (Aggregate)</option>
<option value="agg_range">Peak-to-Peak Range (Aggregate)</option>
</select>
</div>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Description</label>
<input type="text" id="js-udf-modal-desc" class="text-input" placeholder="Calculates temperature in degrees Fahrenheit" style="width: 100%;" />
</div>
<div style="display: grid; grid-template-columns: 1fr 340px; gap: 14px; flex: 1; min-height: 320px;">
<div style="display: flex; flex-direction: column; gap: 6px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<label style="font-size: 12px; font-weight: 600;">JavaScript Implementation <span style="color:var(--rose-dot)">*</span></label>
<span style="font-size: 11px; color: var(--text-muted);">Executed via quickjs engine in pure Rust</span>
</div>
<textarea id="js-udf-modal-code" style="flex: 1; min-height: 280px; background: #090e17; color: #a5f3fc; font-family: var(--font-mono); font-size: 12px; padding: 12px 14px; border-radius: 6px; border: 1px solid #1e293b; line-height: 1.5; resize: none; tab-size: 2;" spellcheck="false"></textarea>
</div>
<div style="display: flex; flex-direction: column; gap: 10px; background: var(--bg-subtle); padding: 14px; border-radius: 6px; border: 1px solid var(--border);">
<div style="font-weight: 700; font-size: 12px; color: var(--text-primary); display: flex; align-items: center; justify-content: space-between;">
<span>🧪 Interactive Test Sandbox</span>
<span class="badge badge-indigo">Preflight Test</span>
</div>
<p style="font-size: 11px; color: var(--text-muted); margin: 0;">
Test execution with sample inputs before saving to the edge runtime:
</p>
<div>
<label style="display: block; font-size: 11px; font-weight: 600; margin-bottom: 4px;">Arguments Array (JSON format)</label>
<input type="text" id="js-udf-sandbox-args" class="text-input" value="[25]" style="width: 100%; font-family: var(--font-mono); font-size: 12px;" />
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="runJsUdfTestSandbox()" style="width: 100%;">
⚡ Run Test in Sandbox
</button>
<div style="flex: 1; display: flex; flex-direction: column; gap: 4px;">
<label style="font-size: 11px; font-weight: 600;">Execution Result</label>
<pre id="js-udf-sandbox-result" style="flex: 1; min-height: 100px; margin: 0; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 4px; padding: 8px 10px; font-family: var(--font-mono); font-size: 11px; overflow-y: auto; color: var(--text-secondary); line-height: 1.4;">Click "Run Test" to evaluate.</pre>
</div>
</div>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; background: #fafbfc; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
<div id="js-udf-modal-status-text" style="font-size: 12px; color: var(--text-muted);">Native zero-dependency JavaScript execution</div>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closeJsUdfModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitJsUdfModal()">Save JavaScript UDF 💾</button>
</div>
</div>
</div>
</div>
<div id="service-register-modal" class="fleet-modal-overlay" style="display: none; z-index: 110;">
<div class="fleet-modal-dialog" style="max-width: 620px; width: 90vw;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">🌐</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);">Register External Service / RPC</strong>
<div style="font-size: 11px; color: var(--text-muted);">Expose external microservices as callable SQL functions</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeServiceRegisterModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="padding: 20px; display: flex; flex-direction: column; gap: 14px;">
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Service Name <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="service-modal-name" class="text-input" placeholder="e.g. ai_vision_service" style="width: 100%; font-family: var(--font-mono);" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Target Service Endpoint Address <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="service-modal-addr" class="text-input" placeholder="e.g. http://127.0.0.1:8080 or grpc://localhost:50051" style="width: 100%; font-family: var(--font-mono);" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Interface Name <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="service-modal-iface" class="text-input" placeholder="e.g. imageClassifier" style="width: 100%; font-family: var(--font-mono);" />
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Exported Methods (comma-separated) <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="service-modal-methods" class="text-input" placeholder="predict, detect_defect, compute_confidence" style="width: 100%; font-family: var(--font-mono);" />
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">These methods will become directly callable in rekuiper SQL queries</div>
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; background: #fafbfc; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closeServiceRegisterModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitServiceRegister()">Register Service 🌐</button>
</div>
</div>
</div>
<div id="file-upload-modal" class="fleet-modal-overlay" style="display: none; z-index: 110;">
<div class="fleet-modal-dialog" style="max-width: 540px; width: 90vw;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">📁</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);">Upload Configuration Artifact</strong>
<div style="font-size: 11px; color: var(--text-muted);">Certificates, models, proto files, and binary assets</div>
</div>
</div>
<button class="btn btn-sm" onclick="closeFileUploadModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="padding: 20px; display: flex; flex-direction: column; gap: 14px;">
<div class="dropzone-box" id="upload-modal-dropzone" onclick="document.getElementById('upload-modal-file-input').click()">
<input type="file" id="upload-modal-file-input" style="display: none;" onchange="handleModalFileSelect(event)" />
<div style="font-size: 28px; margin-bottom: 6px;">📄</div>
<div style="font-weight: 600; font-size: 13px;" id="upload-modal-file-label">Click or Drag & Drop File Here</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Certificates (.pem, .crt), proto (.proto), models (.onnx), etc.</div>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Artifact Filename on Edge Device <span style="color:var(--rose-dot)">*</span></label>
<input type="text" id="upload-modal-name" class="text-input" placeholder="e.g. ca_cert.pem" style="width: 100%; font-family: var(--font-mono);" />
</div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; background: #fafbfc; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closeFileUploadModal()">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="upload-modal-submit-btn" onclick="submitFileUpload()" disabled>Upload Artifact 📁</button>
</div>
</div>
</div>
<div id="plugin-inspect-modal" class="fleet-modal-overlay" style="display: none; z-index: 110;">
<div class="fleet-modal-dialog" style="max-width: 640px; width: 90vw;">
<div class="fleet-modal-header" style="padding: 14px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 18px;">🔍</span>
<div>
<strong style="font-size: 15px; color: var(--text-primary);" id="plugin-inspect-title">Plugin Diagnostics</strong>
<div style="font-size: 11px; color: var(--text-muted);" id="plugin-inspect-subtitle">Runtime symbols, type, and health</div>
</div>
</div>
<button class="btn btn-sm" onclick="closePluginInspectModal()" title="Close">✕</button>
</div>
<div class="fleet-modal-body" style="padding: 20px; display: flex; flex-direction: column; gap: 14px;">
<div style="display: flex; align-items: center; gap: 10px; background: var(--bg-subtle); padding: 10px 14px; border-radius: 6px; border: 1px solid var(--border);">
<span id="plugin-inspect-type-badge" class="badge badge-indigo">WASM</span>
<span id="plugin-inspect-status-badge" class="badge badge-emerald">Installed & Registered</span>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Registered Export Symbols / Functions</label>
<div id="plugin-inspect-symbols-list" style="display: flex; flex-wrap: wrap; gap: 6px;"></div>
</div>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px;">Raw Details</label>
<pre id="plugin-inspect-raw" style="background: #090e17; color: #38bdf8; font-family: var(--font-mono); font-size: 12px; padding: 10px 12px; border-radius: 6px; border: 1px solid #1e293b; margin: 0; line-height: 1.4; overflow-x: auto;"></pre>
</div>
<div class="fleet-modal-footer" style="padding: 10px 20px;">
<button type="button" class="btn btn-sm" onclick="closePluginInspectModal()">Close</button>
</div>
</div>
</div>
<div id="console-auth-modal" class="fleet-modal-overlay" style="display: none; z-index: 999; backdrop-filter: blur(4px);">
<div class="fleet-modal-dialog" style="max-width: 480px; width: 90vw; border: 1px solid var(--border); box-shadow: 0 20px 40px rgba(0,0,0,0.25);">
<div class="fleet-modal-header" style="padding: 16px 20px; background: #fafbfc;">
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 36px; height: 36px; border-radius: 8px; background: #fee2e2; color: #dc2626; display: flex; align-items: center; justify-content: center; font-size: 18px;">
🔒
</div>
<div>
<strong style="font-size: 15px; color: var(--text-primary);">Console Security Guard</strong>
<div style="font-size: 11px; color: var(--text-muted);">rekuiper Edge Access Control</div>
</div>
</div>
<button type="button" class="btn btn-sm" onclick="closeConsoleAuthModal(false)" style="border: none; font-size: 16px;">✕</button>
</div>
<div class="fleet-modal-body" style="padding: 20px; display: flex; flex-direction: column; gap: 14px;">
<p style="font-size: 13px; color: var(--text-secondary); margin: 0; line-height: 1.5;">
This edge stream engine requires an authentication credential to unlock pipelines, schemas, and runtime management.
</p>
<div>
<label style="display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px;">
Console Password or JWT / Fleet Token <span style="color:var(--rose-dot)">*</span>
</label>
<div style="display: flex; gap: 8px;">
<input type="password" id="console-auth-input" class="text-input" placeholder="Enter password or RS256 token..." style="flex: 1; font-family: var(--font-mono); font-size: 13px;" onkeydown="if(event.key==='Enter') submitConsoleAuth()" />
<button type="button" class="btn btn-sm" onclick="toggleConsolePasswordVisibility()" id="console-auth-toggle-btn" title="Show/Hide Password">👁</button>
</div>
</div>
<label style="display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; color: var(--text-secondary);">
<input type="checkbox" id="console-auth-remember" checked />
<span>Keep me authenticated on this workstation (session storage)</span>
</label>
<div id="console-auth-error" style="display: none; padding: 8px 12px; border-radius: 6px; background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; font-size: 12px;"></div>
</div>
<div class="fleet-modal-footer" style="padding: 12px 20px; background: #fafbfc; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center;">
<a href="javascript:void(0)" onclick="openDoc('guide/ui/web_console_user_guide#security--access-control-architecture')" style="font-size: 12px; color: var(--accent); text-decoration: none;">📖 Security Docs</a>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-sm" onclick="closeConsoleAuthModal(false)">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" onclick="submitConsoleAuth()">Unlock Console 🔓</button>
</div>
</div>
</div>
</div>
</main>
<div class="toast-container" id="toast-container"></div>
<script>
const state = {
rules: [],
ruleMetrics: {},
prevTotals: { ts: 0, inCount: 0, outCount: 0, perRuleIn: {}, perRuleOut: {} },
rates: { totalIn: 0, totalOut: 0, perRuleIn: {}, perRuleOut: {} },
streams: [],
tables: [],
connections: [],
declarativeMode: false,
selectedRuleId: null,
pollTimer: null,
pollIntervalMs: 2000
};
const OTLP_PRESETS = {
'/v1/metrics': {
resourceMetrics: [{
resource: {
attributes: [
{ key: "service.name", value: { stringValue: "plc-edge-gateway-01" } },
{ key: "host.arch", value: { stringValue: "aarch64" } }
]
},
scopeMetrics: [{
metrics: [{
name: "industrial.motor.temperature",
unit: "Cel",
gauge: {
dataPoints: [{
timeUnixNano: "1760478000000000000",
asDouble: 84.6,
attributes: [{ key: "line", value: { stringValue: "assembly-A" } }]
}]
}
}]
}]
}]
},
'/v1/logs': {
resourceLogs: [{
resource: {
attributes: [{ key: "service.name", value: { stringValue: "modbus-poller" } }]
},
scopeLogs: [{
logRecords: [{
timeUnixNano: "1760478000000000000",
severityNumber: 17,
severityText: "ERROR",
body: { stringValue: "Modbus slave 1 register 40001 CRC mismatch" },
attributes: [{ key: "slave_id", value: { intValue: "1" } }]
}]
}]
}]
},
'/v1/traces': {
resourceSpans: [{
resource: {
attributes: [{ key: "service.name", value: { stringValue: "opcua-subscriber" } }]
},
scopeSpans: [{
spans: [{
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
spanId: "00f067aa0ba902b7",
name: "ReadNodeId",
startTimeUnixNano: "1760478000000000000",
endTimeUnixNano: "1760478000004500000",
attributes: [{ key: "node_id", value: { stringValue: "ns=2;s=Temperature" } }]
}]
}]
}]
},
'/data/demo': {
temperature: 82.4,
humidity: 48.0,
device_id: "sensor-01"
}
};
function showToast(msg, type = 'info') {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 3500);
}
function fmtNum(n) {
if (n === undefined || n === null || isNaN(n)) return '0';
return Number(n).toLocaleString();
}
function openDoc(slug) {
if (!slug) slug = 'guide/ui/web_console_user_guide';
let path = slug;
let hash = '';
if (path.includes('#')) {
const parts = path.split('#');
path = parts[0];
hash = '#' + parts.slice(1).join('#');
}
path = path.replace(/\.md$/, '').replace(/\.html$/, '');
if (path.startsWith('/')) path = path.slice(1);
const url = `https://rekuiper.indramqtt.com/${path}.html${hash}`;
window.open(url, '_blank');
}
function getAuthToken() {
return sessionStorage.getItem('rekuiper_console_token') || localStorage.getItem('rekuiper_console_token') || '';
}
function setAuthToken(token, remember = true) {
if (!token) {
sessionStorage.removeItem('rekuiper_console_token');
localStorage.removeItem('rekuiper_console_token');
return;
}
if (remember) {
localStorage.setItem('rekuiper_console_token', token);
} else {
sessionStorage.setItem('rekuiper_console_token', token);
}
}
function clearAuthToken() {
sessionStorage.removeItem('rekuiper_console_token');
localStorage.removeItem('rekuiper_console_token');
}
function toggleConsolePasswordVisibility() {
const input = document.getElementById('console-auth-input');
const btn = document.getElementById('console-auth-toggle-btn');
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '🔒';
} else {
input.type = 'password';
btn.textContent = '👁';
}
}
function openConsoleAuthModal(isChallenge = false) {
const modal = document.getElementById('console-auth-modal');
const errBox = document.getElementById('console-auth-error');
if (errBox) errBox.style.display = 'none';
const input = document.getElementById('console-auth-input');
if (input) input.value = getAuthToken();
if (modal) modal.style.display = 'flex';
setTimeout(() => { if (input) input.focus(); }, 100);
if (isChallenge && errBox) {
errBox.textContent = 'Authentication required or token expired. Please unlock the console.';
errBox.style.display = 'block';
}
}
function closeConsoleAuthModal(refresh = false) {
const modal = document.getElementById('console-auth-modal');
if (modal) modal.style.display = 'none';
if (refresh) {
refreshAll();
}
}
async function submitConsoleAuth() {
const input = document.getElementById('console-auth-input');
const remember = document.getElementById('console-auth-remember')?.checked ?? true;
const errBox = document.getElementById('console-auth-error');
const val = (input?.value || '').trim();
if (!val) {
if (errBox) {
errBox.textContent = 'Please enter password or JWT token.';
errBox.style.display = 'block';
}
return;
}
if (errBox) errBox.style.display = 'none';
try {
const res = await fetch('/api/ui/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: val })
});
if (res.ok) {
const data = await res.json().catch(() => ({}));
const token = data.token || val;
setAuthToken(token, remember);
showToast('Console unlocked successfully 🔓', 'success');
closeConsoleAuthModal(true);
await checkConsoleAuthStatus();
} else {
const probe = await fetch('/rules', {
headers: { 'Authorization': `Bearer ${val}` }
});
if (probe.ok) {
setAuthToken(val, remember);
showToast('JWT / Token authenticated 🔓', 'success');
closeConsoleAuthModal(true);
await checkConsoleAuthStatus();
} else {
if (errBox) {
errBox.textContent = 'Invalid credentials or token rejected by server.';
errBox.style.display = 'block';
}
}
}
} catch (err) {
if (errBox) {
errBox.textContent = `Authentication error: ${err.message}`;
errBox.style.display = 'block';
}
}
}
async function checkConsoleAuthStatus() {
const indicator = document.getElementById('topbar-auth-indicator');
const token = getAuthToken();
try {
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch('/api/ui/auth-status', { headers });
if (res.ok) {
const info = await res.json();
if (!info.auth_required) {
if (indicator) indicator.innerHTML = '🟢 <span style="font-size:11px; font-weight:600;">Open Mode</span>';
} else if (info.authenticated) {
if (indicator) indicator.innerHTML = '🔒 <span style="font-size:11px; font-weight:600; color:#10b981;">Protected (Unlocked)</span>';
} else {
if (indicator) indicator.innerHTML = '⚠️ <span style="font-size:11px; font-weight:600; color:#f59e0b;">Locked</span>';
if (token) clearAuthToken();
}
}
} catch (_) {}
}
function dismissQuickStartBanner() {
const banner = document.getElementById('quickstart-banner');
if (banner) banner.style.display = 'none';
try {
localStorage.setItem('rekuiper_dismiss_quickstart', 'true');
} catch (_) {}
}
function checkQuickStartBannerPreference() {
try {
if (localStorage.getItem('rekuiper_dismiss_quickstart') === 'true') {
const banner = document.getElementById('quickstart-banner');
if (banner) banner.style.display = 'none';
}
} catch (_) {}
}
function launchGuidedQuickStartPipeline() {
openNewRuleDesigner();
loadRuleRecipe('overheat');
showToast('Guided Pipeline pre-configured! Review Steps 1-3 and deploy 🪄', 'info');
}
async function apiFetch(path, options = {}) {
const fetchOpts = Object.assign({}, options);
const token = getAuthToken();
fetchOpts.headers = Object.assign({}, fetchOpts.headers || {});
if (token && !fetchOpts.headers['Authorization']) {
fetchOpts.headers['Authorization'] = `Bearer ${token}`;
}
if (fetchOpts.body && typeof fetchOpts.body === 'object' && !(fetchOpts.body instanceof FormData)) {
fetchOpts.headers['Content-Type'] = 'application/json';
fetchOpts.body = JSON.stringify(fetchOpts.body);
}
const res = await fetch(path, fetchOpts);
if (res.status === 401 && path !== '/api/ui/login' && path !== '/api/ui/auth-status') {
openConsoleAuthModal(true);
}
const contentType = res.headers.get('content-type') || '';
let body = null;
if (contentType.includes('application/json')) {
body = await res.json().catch(() => null);
} else {
body = await res.text().catch(() => '');
}
return { ok: res.ok, status: res.status, body };
}
function extractMetricSum(metricsObj, suffix) {
if (!metricsObj || typeof metricsObj !== 'object') return 0;
let total = 0;
const normSuffix = String(suffix).toLowerCase().replace(/_/g, '');
for (const [k, v] of Object.entries(metricsObj)) {
if (typeof v !== 'number') continue;
const normKey = String(k).toLowerCase().replace(/_/g, '');
if (normKey === normSuffix || normKey.endsWith(normSuffix)) {
total += v;
}
}
return total;
}
async function refreshAll(manual = false) {
try {
const [pingRes, rulesRes, streamsRes, tablesRes, connsRes, promRes] = await Promise.all([
apiFetch('/'),
apiFetch('/rules'),
apiFetch('/streams'),
apiFetch('/tables'),
apiFetch('/connections'),
apiFetch('/metrics')
]);
const connBadge = document.getElementById('conn-badge');
const connText = document.getElementById('conn-text');
if (rulesRes.ok) {
connBadge.className = 'badge badge-emerald';
connText.textContent = (pingRes.body && pingRes.body.version)
? `v${pingRes.body.version}`
: 'Online';
if (pingRes.body && pingRes.body.uptime) {
document.getElementById('kpi-uptime').textContent = `Up ${pingRes.body.uptime}s`;
}
} else {
connBadge.className = 'badge badge-rose';
connText.textContent = 'Disconnected';
}
const promText = typeof promRes.body === 'string' ? promRes.body : '';
if (
(pingRes.body && pingRes.body.declarative_mode === true) ||
promText.includes('rekuiper_declarative_reload_failures_total')
) {
setDeclarativeBadge(true);
}
const rawRules = Array.isArray(rulesRes.body) ? rulesRes.body : [];
state.rules = rawRules.map(r => {
if (typeof r === 'string') return { id: r, status: 'Running' };
return {
id: r.id || r.name || 'unknown',
status: r.status || 'Running',
message: r.message || ''
};
});
const statusPromises = state.rules.map(r =>
apiFetch(`/rules/${encodeURIComponent(r.id)}/status`).then(res => [r.id, res.ok ? res.body : {}])
);
const statusEntries = await Promise.all(statusPromises);
const nextMetrics = {};
for (const [id, m] of statusEntries) {
nextMetrics[id] = m || {};
}
state.ruleMetrics = nextMetrics;
const now = Date.now();
const dtSec = state.prevTotals.ts > 0 ? Math.max((now - state.prevTotals.ts) / 1000, 0.2) : 0;
let sumIn = 0;
let sumOut = 0;
let sumFiltered = 0;
let sumCache = 0;
let sumDlq = 0;
let sumExc = 0;
for (const r of state.rules) {
const m = state.ruleMetrics[r.id] || {};
const rIn = extractMetricSum(m, 'records_in_total');
const rOut = extractMetricSum(m, 'records_out_total');
const rFilt = extractMetricSum(m, 'records_filtered_total');
const rCache = extractMetricSum(m, 'sink_cache_len') || extractMetricSum(m, 'buffer_length');
const rDlq = extractMetricSum(m, 'dlq_records_total');
const rExc = extractMetricSum(m, 'exceptions_total');
sumIn += rIn;
sumOut += rOut;
sumFiltered += rFilt;
sumCache += rCache;
sumDlq += rDlq;
sumExc += rExc;
if (dtSec > 0 && state.prevTotals.perRuleIn[r.id] !== undefined) {
state.rates.perRuleIn[r.id] = Math.max(0, Math.round((rIn - state.prevTotals.perRuleIn[r.id]) / dtSec));
state.rates.perRuleOut[r.id] = Math.max(0, Math.round((rOut - state.prevTotals.perRuleOut[r.id]) / dtSec));
} else {
state.rates.perRuleIn[r.id] = 0;
state.rates.perRuleOut[r.id] = 0;
}
state.prevTotals.perRuleIn[r.id] = rIn;
state.prevTotals.perRuleOut[r.id] = rOut;
}
if (dtSec > 0 && state.prevTotals.ts > 0) {
state.rates.totalIn = Math.max(0, Math.round((sumIn - state.prevTotals.inCount) / dtSec));
state.rates.totalOut = Math.max(0, Math.round((sumOut - state.prevTotals.outCount) / dtSec));
}
state.prevTotals.ts = now;
state.prevTotals.inCount = sumIn;
state.prevTotals.outCount = sumOut;
state.streams = Array.isArray(streamsRes.body) ? streamsRes.body : [];
state.tables = Array.isArray(tablesRes.body) ? tablesRes.body : [];
state.connections = Array.isArray(connsRes.body)
? connsRes.body
: (connsRes.body && typeof connsRes.body === 'object' ? Object.entries(connsRes.body) : []);
updateKpiCards(sumIn, sumOut, sumFiltered, sumCache, sumDlq, sumExc);
renderRulesTable();
renderCatalogTables();
fetchSchemas();
if (document.getElementById('view-metrics').style.display !== 'none' && promText) {
document.getElementById('prom-metrics-box').textContent = promText;
}
if (state.selectedRuleId) {
await refreshInspector(state.selectedRuleId);
}
if (typeof updateStudioThroughputFromRule === 'function' && studio.ruleId) {
updateStudioThroughputFromRule(studio.ruleId);
}
if (manual) showToast('Dashboard state refreshed', 'success');
} catch (err) {
document.getElementById('conn-badge').className = 'badge badge-rose';
document.getElementById('conn-text').textContent = 'Offline';
}
}
function setDeclarativeBadge(isDeclarative) {
state.declarativeMode = isDeclarative;
const badge = document.getElementById('mode-badge');
const reloadBtn = document.getElementById('reload-manifest-btn');
if (isDeclarative) {
badge.className = 'badge badge-indigo';
badge.textContent = 'Declarative GitOps (Read-Only)';
reloadBtn.style.display = 'inline-flex';
} else {
badge.className = 'badge badge-slate';
badge.textContent = 'Mutable Mode';
reloadBtn.style.display = 'none';
}
}
function updateKpiCards(sumIn, sumOut, sumFiltered, sumCache, sumDlq, sumExc) {
const totalRules = state.rules.length;
const runningRules = state.rules.filter(r => String(r.status).toLowerCase().includes('running')).length;
const stoppedRules = totalRules - runningRules;
document.getElementById('kpi-rules-count').textContent = `${runningRules} / ${totalRules}`;
document.getElementById('kpi-rules-stopped').textContent = `${stoppedRules} stopped`;
const pctEl = document.getElementById('kpi-rules-pct');
pctEl.textContent = totalRules > 0 ? `${Math.round((runningRules / totalRules) * 100)}%` : 'Idle';
pctEl.className = stoppedRules > 0 ? 'badge badge-amber' : 'badge badge-emerald';
document.getElementById('kpi-in-rate').innerHTML = `${fmtNum(state.rates.totalIn)} <span style="font-size: 13px; font-weight: 500; color: var(--text-muted);">rec/s</span>`;
document.getElementById('kpi-in-total').textContent = fmtNum(sumIn);
document.getElementById('kpi-out-rate').innerHTML = `${fmtNum(state.rates.totalOut)} <span style="font-size: 13px; font-weight: 500; color: var(--text-muted);">rec/s</span>`;
document.getElementById('kpi-out-total').textContent = fmtNum(sumOut);
document.getElementById('kpi-catalog-count').textContent = fmtNum(state.streams.length + state.tables.length);
document.getElementById('kpi-streams-sub').textContent = `${state.streams.length} Streams`;
document.getElementById('kpi-tables-sub').textContent = `${state.tables.length} Tables`;
document.getElementById('kpi-cache-depth').textContent = fmtNum(sumCache);
document.getElementById('kpi-filtered-total').textContent = fmtNum(sumFiltered);
document.getElementById('kpi-dlq-total').textContent = fmtNum(sumDlq);
document.getElementById('kpi-exc-total').textContent = fmtNum(sumExc);
const dlqCard = document.getElementById('kpi-dlq-card');
const dlqBadge = document.getElementById('kpi-dlq-badge');
if (sumDlq > 0 || sumExc > 0) {
dlqCard.classList.add('alert-card');
dlqBadge.className = 'badge badge-rose';
dlqBadge.textContent = `${fmtNum(sumDlq)} Poison Pills`;
} else {
dlqCard.classList.remove('alert-card');
dlqBadge.className = 'badge badge-emerald';
dlqBadge.textContent = 'Clean';
}
}
function renderRulesTable() {
const tbody = document.getElementById('rules-tbody');
const query = (document.getElementById('rule-search').value || '').toLowerCase().trim();
const statusFilter = document.getElementById('rule-status-filter').value;
const filtered = state.rules.filter(r => {
const m = state.ruleMetrics[r.id] || {};
const rDlq = extractMetricSum(m, 'dlq_records_total') + extractMetricSum(m, 'exceptions_total');
const isRunning = String(r.status).toLowerCase().includes('running');
if (query && !r.id.toLowerCase().includes(query) && !String(r.status).toLowerCase().includes(query)) {
return false;
}
if (statusFilter === 'running' && !isRunning) return false;
if (statusFilter === 'stopped' && isRunning) return false;
if (statusFilter === 'dlq' && rDlq === 0) return false;
return true;
});
document.getElementById('table-rule-count').textContent = filtered.length;
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="empty-state">No matching rules found.</td></tr>`;
return;
}
tbody.innerHTML = filtered.map(r => {
const m = state.ruleMetrics[r.id] || {};
const rIn = extractMetricSum(m, 'records_in_total');
const rOut = extractMetricSum(m, 'records_out_total');
const rFilt = extractMetricSum(m, 'records_filtered_total');
const rCache = extractMetricSum(m, 'sink_cache_len') || extractMetricSum(m, 'buffer_length');
const rDlq = extractMetricSum(m, 'dlq_records_total');
const rExc = extractMetricSum(m, 'exceptions_total');
const inRate = state.rates.perRuleIn[r.id] || 0;
const outRate = state.rates.perRuleOut[r.id] || 0;
const isRunning = String(r.status).toLowerCase().includes('running');
const isSelected = state.selectedRuleId === r.id;
const statusBadge = isRunning
? `<span class="badge badge-emerald"><span class="badge-dot"></span>Running</span>`
: `<span class="badge badge-amber"><span class="badge-dot"></span>${escapeHtml(r.status)}</span>`;
const dlqCell = (rDlq > 0 || rExc > 0)
? `<span class="badge badge-rose tabular"><span class="badge-dot"></span>${fmtNum(rDlq)} DLQ / ${fmtNum(rExc)} Exc</span>`
: `<span class="badge badge-slate tabular">0</span>`;
const toggleBtn = state.declarativeMode
? ''
: `<button class="btn btn-sm" onclick="event.stopPropagation(); toggleRuleState('${escapeHtml(r.id)}', ${isRunning})">
${isRunning ? 'Stop' : 'Start'}
</button>`;
return `
<tr class="clickable-row ${isSelected ? 'selected-row' : ''}" onclick="selectRule('${escapeHtml(r.id)}')">
<td style="font-family: var(--font-mono); font-weight: 600;">${escapeHtml(r.id)}</td>
<td>${statusBadge}</td>
<td class="tabular">${fmtNum(rIn)} <span class="rate-pill">(${inRate}/s)</span></td>
<td class="tabular">${fmtNum(rOut)} <span class="rate-pill">(${outRate}/s)</span></td>
<td class="tabular">${fmtNum(rFilt)}</td>
<td class="tabular">${fmtNum(rCache)}</td>
<td>${dlqCell}</td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" title="Open in 3-Step Designer" style="background: #1e293b; color: #a78bfa; border-color: #8b5cf6;" onclick="event.stopPropagation(); openExistingRuleDesigner('${escapeHtml(r.id)}')">Designer 🪄</button>
<button class="btn btn-sm" title="Edit in Visual Studio" style="background: #1e293b; color: #fb923c; border-color: #ea580c;" onclick="event.stopPropagation(); loadRuleIntoStudio('${escapeHtml(r.id)}')">Studio 🎨</button>
<button class="btn btn-sm" onclick="event.stopPropagation(); selectRule('${escapeHtml(r.id)}')">Inspect</button>
${toggleBtn}
</div>
</td>
</tr>
`;
}).join('');
}
async function selectRule(ruleId) {
state.selectedRuleId = ruleId;
document.getElementById('view-rules').classList.add('with-inspector');
document.getElementById('rule-inspector').style.display = 'flex';
renderRulesTable();
await refreshInspector(ruleId);
}
function closeInspector() {
state.selectedRuleId = null;
document.getElementById('view-rules').classList.remove('with-inspector');
document.getElementById('rule-inspector').style.display = 'none';
renderRulesTable();
}
async function refreshInspector(ruleId) {
document.getElementById('insp-rule-id').textContent = ruleId;
const ruleEntry = state.rules.find(r => r.id === ruleId);
const isRunning = ruleEntry ? String(ruleEntry.status).toLowerCase().includes('running') : true;
const badge = document.getElementById('insp-status-badge');
badge.className = isRunning ? 'badge badge-emerald' : 'badge badge-amber';
badge.textContent = ruleEntry ? ruleEntry.status : 'Running';
const [defRes, dlqRes] = await Promise.all([
apiFetch(`/rules/${encodeURIComponent(ruleId)}`),
apiFetch(`/rules/${encodeURIComponent(ruleId)}/errors`)
]);
if (defRes.ok && defRes.body) {
document.getElementById('insp-sql').textContent = defRes.body.sql || 'N/A';
const opts = defRes.body.options || {};
const onError = opts.onError || opts.on_error || 'drop';
document.getElementById('insp-options-summary').textContent = `onError=${onError}`;
document.getElementById('insp-actions-json').textContent = JSON.stringify(
{ actions: defRes.body.actions || [], options: opts },
null,
2
);
}
if (dlqRes.ok && dlqRes.body) {
const byStage = dlqRes.body.by_stage || {};
updateStageBox('st-codec', 'st-codec-val', byStage.codec_decode || byStage.CodecDecode || 0);
updateStageBox('st-schema', 'st-schema-val', byStage.schema_validation || byStage.SchemaValidation || 0);
updateStageBox('st-sql', 'st-sql-val', byStage.sql_eval || byStage.SqlEval || 0);
updateStageBox('st-tpl', 'st-tpl-val', byStage.data_template || byStage.DataTemplate || 0);
updateStageBox('st-sink', 'st-sink-val', byStage.sink_delivery || byStage.SinkDelivery || 0);
const errors = Array.isArray(dlqRes.body.errors) ? dlqRes.body.errors : [];
document.getElementById('insp-dlq-count-label').textContent = `${errors.length} buffered`;
const listEl = document.getElementById('insp-dlq-list');
if (errors.length === 0) {
listEl.innerHTML = `
<div class="empty-state" style="padding: 18px; background: var(--bg-canvas); border-radius: 6px; border: 1px dashed var(--border);">
Zero poison pills in ring buffer.
</div>`;
} else {
listEl.innerHTML = errors.slice().reverse().slice(0, 25).map(env => {
const ts = env.timestamp ? new Date(env.timestamp).toISOString() : '';
const payloadPreview = env.raw_payload
? `<div class="code-block" style="margin-top:4px; font-size:11px;">${escapeHtml(env.raw_payload)}</div>`
: (env.tuple ? `<div class="code-block" style="margin-top:4px; font-size:11px;">${escapeHtml(JSON.stringify(env.tuple))}</div>` : '');
return `
<div class="dlq-item">
<div class="dlq-item-header">
<span class="badge badge-rose">${escapeHtml(String(env.stage || 'error'))}</span>
<span class="tabular">${escapeHtml(ts)}</span>
</div>
<div class="dlq-error-msg">${escapeHtml(env.error || 'Unknown error')}</div>
${payloadPreview}
</div>
`;
}).join('');
}
}
}
function updateStageBox(boxId, valId, count) {
const box = document.getElementById(boxId);
const val = document.getElementById(valId);
val.textContent = fmtNum(count);
if (count > 0) {
box.classList.add('has-err');
} else {
box.classList.remove('has-err');
}
}
async function clearSelectedRuleDlq() {
if (!state.selectedRuleId) return;
const res = await apiFetch(`/rules/${encodeURIComponent(state.selectedRuleId)}/errors`, { method: 'DELETE' });
if (res.ok) {
showToast(`Cleared DLQ ring buffer for '${state.selectedRuleId}'`, 'success');
await refreshAll();
} else {
showToast(`Failed to clear DLQ (${res.status})`, 'error');
}
}
async function toggleRuleState(ruleId, isRunning) {
const action = isRunning ? 'stop' : 'start';
const res = await apiFetch(`/rules/${encodeURIComponent(ruleId)}/${action}`, { method: 'POST' });
if (res.status === 403) {
setDeclarativeBadge(true);
showToast('Blocked: Instance is running in read-only Declarative GitOps Mode', 'error');
return;
}
if (res.ok) {
showToast(`Rule '${ruleId}' ${action}ped`, 'success');
await refreshAll();
} else {
showToast(`Failed to ${action} rule '${ruleId}'`, 'error');
}
}
async function reloadDeclarativeManifest() {
const res = await apiFetch('/declarative/reload', { method: 'POST' });
if (res.ok) {
showToast('Declarative manifest reconciled & reloaded', 'success');
await refreshAll();
} else {
const msg = (res.body && res.body.message) ? res.body.message : `Reload failed (${res.status})`;
showToast(msg, 'error');
}
}
function renderCatalogTables() {
document.getElementById('catalog-streams-count').textContent = state.streams.length;
document.getElementById('catalog-tables-count').textContent = state.tables.length;
document.getElementById('catalog-conns-count').textContent = state.connections.length;
const sBody = document.getElementById('streams-tbody');
sBody.innerHTML = state.streams.length === 0
? `<tr><td colspan="3" class="empty-state">No streams defined.</td></tr>`
: state.streams.map(s => {
const name = typeof s === 'string' ? s : (s.name || s.id || JSON.stringify(s));
const detail = typeof s === 'object' ? JSON.stringify(s) : 'Active Stream Source';
return `
<tr>
<td style="font-family:var(--font-mono);font-weight:600;">${escapeHtml(name)}</td>
<td style="color:var(--text-secondary);font-family:var(--font-mono);font-size:12px;">${escapeHtml(detail)}</td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" onclick="openExistingStreamDesigner('stream', '${escapeHtml(name)}')" title="Edit / View Stream">Edit</button>
<button class="btn btn-sm" style="color:var(--rose-text);" onclick="deleteCatalogItem('stream', '${escapeHtml(name)}')" title="Delete Stream">Delete</button>
</div>
</td>
</tr>`;
}).join('');
const tBody = document.getElementById('tables-tbody');
tBody.innerHTML = state.tables.length === 0
? `<tr><td colspan="3" class="empty-state">No lookup tables defined.</td></tr>`
: state.tables.map(t => {
const name = typeof t === 'string' ? t : (t.name || t.id || JSON.stringify(t));
const detail = typeof t === 'object' ? JSON.stringify(t) : 'Lookup / Scan Table';
return `
<tr>
<td style="font-family:var(--font-mono);font-weight:600;">${escapeHtml(name)}</td>
<td style="color:var(--text-secondary);font-family:var(--font-mono);font-size:12px;">${escapeHtml(detail)}</td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" onclick="openExistingStreamDesigner('table', '${escapeHtml(name)}')" title="Edit / View Table">Edit</button>
<button class="btn btn-sm" style="color:var(--rose-text);" onclick="deleteCatalogItem('table', '${escapeHtml(name)}')" title="Delete Table">Delete</button>
</div>
</td>
</tr>`;
}).join('');
renderConnectionsTable();
renderSchemasTable();
}
function switchTab(tab) {
['rules', 'catalog', 'plugins', 'ingest', 'metrics', 'studio', 'fleet'].forEach(t => {
const el = document.getElementById(`view-${t}`);
const btn = document.getElementById(`tab-btn-${t}`);
if (el) {
el.style.display = (t === tab)
? (t === 'rules' || t === 'catalog' || t === 'ingest' ? 'grid' : (t === 'studio' || t === 'fleet' ? 'flex' : 'block'))
: 'none';
}
if (btn) {
btn.classList.toggle('active', t === tab);
}
});
document.getElementById('rules-filter-bar').style.display = (tab === 'rules') ? 'flex' : 'none';
if (tab === 'plugins') fetchPluginsHubData();
if (tab === 'metrics') fetchPrometheusMetrics();
if (tab === 'studio') initStudioIfNeeded();
if (tab === 'fleet') initFleetIfNeeded();
}
async function fetchPrometheusMetrics() {
const res = await apiFetch('/metrics');
document.getElementById('prom-metrics-box').textContent = res.ok ? res.body : `Failed to load /metrics (${res.status})`;
}
function loadPresetPayload(endpoint) {
document.getElementById('ingest-custom-path').value = endpoint;
const preset = OTLP_PRESETS[endpoint] || OTLP_PRESETS['/v1/metrics'];
document.getElementById('ingest-payload').value = JSON.stringify(preset, null, 2);
}
async function sendIngestPayload() {
const path = document.getElementById('ingest-custom-path').value.trim();
const rawBody = document.getElementById('ingest-payload').value;
const outBox = document.getElementById('ingest-response');
outBox.textContent = `Sending POST ${path}...`;
try {
const res = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: rawBody
});
outBox.textContent = `HTTP ${res.status}\n\n` + (typeof res.body === 'object' ? JSON.stringify(res.body, null, 2) : String(res.body));
if (res.ok) {
showToast(`Ingested payload via ${path}`, 'success');
await refreshAll();
} else {
showToast(`Ingest returned HTTP ${res.status}`, 'error');
}
} catch (e) {
outBox.textContent = `Request Error: ${e.message}`;
}
}
function updatePollInterval(val) {
state.pollIntervalMs = Number(val);
if (state.pollTimer) clearInterval(state.pollTimer);
if (state.pollIntervalMs > 0) {
state.pollTimer = setInterval(() => refreshAll(false), state.pollIntervalMs);
}
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const studio = {
palette: [],
paletteFilter: 'all',
ruleId: 'stream_pipeline_01',
nodes: [],
edges: [],
selectedNodeId: null,
selectedEdgeId: null,
zoom: 1.0,
pan: { x: 0, y: 0 },
isPanning: false,
panStart: { x: 0, y: 0 },
draggingNodeId: null,
nodeDragOffset: { x: 0, y: 0 },
wireDrag: null,
commsWs: null,
terminalLines: {},
droppedCounts: {},
connectorStatuses: {},
nodeSeq: 1,
initialized: false
};
async function initStudioIfNeeded() {
if (studio.initialized) return;
studio.initialized = true;
const ruleIdInput = document.getElementById('studio-rule-id');
if (ruleIdInput) {
ruleIdInput.addEventListener('input', (e) => {
studio.ruleId = e.target.value.trim() || 'stream_pipeline_01';
syncJsonFromCanvas();
});
}
setupCanvasInteractions();
await loadStudioPalette();
loadStudioPreset('iot_switch');
initCommsWebSocket();
}
function setupCanvasInteractions() {
const container = document.getElementById('studio-canvas-container');
if (!container) return;
container.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.08 : -0.08;
zoomCanvas(delta);
}, { passive: false });
container.addEventListener('mousedown', (e) => {
if (e.target.closest('.studio-node') || e.target.closest('.canvas-controls') || e.target.closest('.port-dot') || e.target.closest('.studio-wire')) {
return;
}
studio.isPanning = true;
studio.panStart = { x: e.clientX - studio.pan.x, y: e.clientY - studio.pan.y };
selectNode(null);
selectEdge(null);
});
window.addEventListener('mousemove', (e) => {
if (studio.isPanning) {
studio.pan.x = e.clientX - studio.panStart.x;
studio.pan.y = e.clientY - studio.panStart.y;
applyCanvasTransform();
return;
}
if (studio.draggingNodeId) {
const node = studio.nodes.find(n => n.id === studio.draggingNodeId);
if (node) {
const cRect = container.getBoundingClientRect();
const curCanvasX = (e.clientX - cRect.left - studio.pan.x) / studio.zoom;
const curCanvasY = (e.clientY - cRect.top - studio.pan.y) / studio.zoom;
node.x = Math.round(curCanvasX - studio.nodeDragOffset.x);
node.y = Math.round(curCanvasY - studio.nodeDragOffset.y);
updateNodeDomPosition(node);
updateWirePaths();
}
return;
}
if (studio.wireDrag) {
const rect = container.getBoundingClientRect();
const curX = (e.clientX - rect.left - studio.pan.x) / studio.zoom;
const curY = (e.clientY - rect.top - studio.pan.y) / studio.zoom;
updateDragWire(curX, curY);
}
});
window.addEventListener('mouseup', () => {
if (studio.isPanning) studio.isPanning = false;
if (studio.draggingNodeId) {
studio.draggingNodeId = null;
syncJsonFromCanvas();
}
if (studio.wireDrag) {
cancelWireDrag();
}
});
container.addEventListener('dragover', (e) => e.preventDefault());
container.addEventListener('drop', (e) => {
e.preventDefault();
const raw = e.dataTransfer.getData('text/plain');
if (!raw) return;
let nodeType = raw;
let nodeCat = null;
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.type) {
nodeType = parsed.type;
nodeCat = parsed.category;
}
} catch (err) {}
const rect = container.getBoundingClientRect();
const x = Math.round((e.clientX - rect.left - studio.pan.x) / studio.zoom - 120);
const y = Math.round((e.clientY - rect.top - studio.pan.y) / studio.zoom - 44);
addNodeToCanvas(nodeType, x, y, nodeCat);
});
}
function applyCanvasTransform() {
const nodesLayer = document.getElementById('studio-nodes-layer');
const svgLayer = document.getElementById('studio-svg-layer');
const transform = `translate(${studio.pan.x}px, ${studio.pan.y}px) scale(${studio.zoom})`;
if (nodesLayer) nodesLayer.style.transform = transform;
if (svgLayer) svgLayer.style.transform = transform;
const zoomText = document.getElementById('canvas-zoom-level');
if (zoomText) zoomText.textContent = `${Math.round(studio.zoom * 100)}%`;
}
function zoomCanvas(delta) {
studio.zoom = Math.min(Math.max(studio.zoom + delta, 0.4), 2.2);
applyCanvasTransform();
}
function resetCanvasTransform() {
studio.zoom = 1.0;
studio.pan = { x: 40, y: 40 };
applyCanvasTransform();
}
async function loadStudioPalette() {
try {
const res = await apiFetch('/api/v1/palette');
if (res.ok && res.body && Array.isArray(res.body.nodes)) {
studio.palette = res.body.nodes;
} else {
studio.palette = getDefaultPaletteFallback();
}
} catch (e) {
studio.palette = getDefaultPaletteFallback();
}
renderPalette(studio.palette);
}
const STUDIO_ICONS = {
radio: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>`,
cpu: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg>`,
server: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
globe: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
layers: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>`,
'play-circle': `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>`,
'git-branch': `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`,
shield: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
clock: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
filter: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>`,
columns: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>`,
box: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
database: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`,
terminal: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
file: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
activity: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
zap: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
send: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`
};
function getStudioIconSvg(nameOrType, category) {
if (!nameOrType && !category) return STUDIO_ICONS.zap;
const key = String(nameOrType).toLowerCase().trim();
if (STUDIO_ICONS[key]) return STUDIO_ICONS[key];
const typeMap = {
mqtt: 'radio',
modbus: 'cpu',
opcua: 'server',
httppull: 'globe',
rest: 'globe',
kafka: 'layers',
simulator: 'play-circle',
switch: 'git-branch',
rate_limit: 'shield',
watchdog: 'clock',
filter: 'filter',
pick: 'columns',
window: 'box',
sql: 'database',
influx: 'database',
influx2: 'database',
tdengine: 'database',
iotdb: 'database',
redis: 'layers',
parquet: 'file',
file: 'file',
otlp: 'activity',
websocket: 'radio',
log: 'terminal',
nop: 'zap'
};
if (typeMap[key] && STUDIO_ICONS[typeMap[key]]) {
return STUDIO_ICONS[typeMap[key]];
}
if (category === 'source') return STUDIO_ICONS.radio;
if (category === 'sink') return STUDIO_ICONS.terminal;
if (category === 'operator') return STUDIO_ICONS.zap;
return STUDIO_ICONS.zap;
}
function getNodeCatColor(category, type) {
if (type === 'switch') return '#ea580c';
if (type === 'watchdog') return '#e11d48';
if (type === 'rate_limit') return '#3b82f6';
if (category === 'source') return '#0284c7';
if (category === 'sink') return '#059669';
return '#6366f1';
}
function getDefaultPaletteFallback() {
return [
{ category: "source", type: "mqtt", label: "MQTT Source", color: "#0284c7", icon: "radio", inputs: 0, outputs: 1, multi_port: false, properties: [{ name: "datasource", type: "string", label: "Topic", default: "demo" }] },
{ category: "source", type: "modbus", label: "Modbus PLC", color: "#0284c7", icon: "cpu", inputs: 0, outputs: 1, multi_port: false, properties: [{ name: "datasource", type: "string", label: "Stream", default: "plc" }] },
{ category: "source", type: "opcua", label: "OPC-UA Source", color: "#0284c7", icon: "server", inputs: 0, outputs: 1, multi_port: false, properties: [{ name: "datasource", type: "string", label: "Stream", default: "opcua" }] },
{ category: "source", type: "httppull", label: "HTTP Pull", color: "#0284c7", icon: "globe", inputs: 0, outputs: 1, multi_port: false, properties: [{ name: "url", type: "string", label: "Endpoint URL", default: "http://127.0.0.1:8080/data" }] },
{ category: "source", type: "kafka", label: "Kafka Source", color: "#0284c7", icon: "layers", inputs: 0, outputs: 1, multi_port: false, properties: [{ name: "brokers", type: "string", label: "Brokers", default: "127.0.0.1:9092" }, { name: "topic", type: "string", label: "Topic", default: "telemetry" }] },
{ category: "source", type: "simulator", label: "Simulator / Mock", color: "#0284c7", icon: "play-circle", inputs: 0, outputs: 1, multi_port: false, properties: [{ name: "interval_ms", type: "number", label: "Interval (ms)", default: 1000 }] },
{ category: "operator", type: "switch", label: "Switch Router", color: "#ea580c", icon: "git-branch", inputs: 1, outputs: 2, multi_port: true, properties: [{ name: "cases", type: "json", label: "Cases", default: [{ case: "temperature > 50", port: 0 }, { case: "temperature <= 50", port: 1 }] }] },
{ category: "operator", type: "rate_limit", label: "Rate Limiter", color: "#3b82f6", icon: "shield", inputs: 1, outputs: 1, multi_port: false, properties: [{ name: "rate_limit", type: "number", label: "Limit (msg/s)", default: 10 }] },
{ category: "operator", type: "watchdog", label: "Watchdog Timeout", color: "#e11d48", icon: "clock", inputs: 1, outputs: 1, multi_port: false, properties: [{ name: "timeout_ms", type: "number", label: "Timeout (ms)", default: 5000 }] },
{ category: "operator", type: "filter", label: "Filter", color: "#6366f1", icon: "filter", inputs: 1, outputs: 1, multi_port: false, properties: [{ name: "expr", type: "string", label: "Expression", default: "temperature > 25" }] },
{ category: "operator", type: "pick", label: "Pick Projection", color: "#6366f1", icon: "columns", inputs: 1, outputs: 1, multi_port: false, properties: [{ name: "fields", type: "string", label: "Fields (comma-separated)", default: "id, ts, value" }] },
{ category: "operator", type: "window", label: "Window Aggregator", color: "#6366f1", icon: "box", inputs: 1, outputs: 1, multi_port: false, properties: [{ name: "window_type", type: "string", label: "Type", default: "tumbling" }] },
{ category: "operator", type: "sql", label: "SQL Transform", color: "#6366f1", icon: "database", inputs: 1, outputs: 1, multi_port: false, properties: [{ name: "sql", type: "string", label: "SQL Query", default: "SELECT * FROM src" }] },
{ category: "sink", type: "mqtt", label: "MQTT Sink", color: "#059669", icon: "radio", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "topic", type: "string", label: "Target Topic", default: "alerts" }] },
{ category: "sink", type: "rest", label: "REST / HTTP Sink", color: "#059669", icon: "globe", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "url", type: "string", label: "URL", default: "http://127.0.0.1:8080/ingest" }] },
{ category: "sink", type: "log", label: "Log Sink", color: "#059669", icon: "terminal", inputs: 1, outputs: 0, multi_port: false, properties: [] },
{ category: "sink", type: "kafka", label: "Kafka Sink", color: "#059669", icon: "layers", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "brokers", type: "string", label: "Brokers", default: "127.0.0.1:9092" }, { name: "topic", type: "string", label: "Topic", default: "rekuiper_out" }] },
{ category: "sink", type: "influx", label: "InfluxDB v1", color: "#059669", icon: "database", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "addr", type: "string", label: "Server URL", default: "http://127.0.0.1:8086" }] },
{ category: "sink", type: "influx2", label: "InfluxDB v2", color: "#059669", icon: "database", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "addr", type: "string", label: "Server URL", default: "http://127.0.0.1:8086" }, { name: "token", type: "string", label: "Token", default: "******" }] },
{ category: "sink", type: "tdengine", label: "TDengine 3.x", color: "#059669", icon: "database", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "host", type: "string", label: "Host", default: "localhost" }, { name: "database", type: "string", label: "DB", default: "power" }] },
{ category: "sink", type: "iotdb", label: "Apache IoTDB", color: "#059669", icon: "database", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "addr", type: "string", label: "Server URL", default: "http://127.0.0.1:18080" }] },
{ category: "sink", type: "opcua", label: "OPC-UA Sink", color: "#059669", icon: "server", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "endpoint", type: "string", label: "Server URL", default: "opc.tcp://127.0.0.1:4840" }] },
{ category: "sink", type: "modbus", label: "Modbus PLC Sink", color: "#059669", icon: "cpu", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "endpoint", type: "string", label: "TCP Endpoint", default: "127.0.0.1:502" }] },
{ category: "sink", type: "redis", label: "Redis Sink", color: "#059669", icon: "layers", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "addr", type: "string", label: "Redis URL", default: "redis://127.0.0.1:6379" }] },
{ category: "sink", type: "parquet", label: "Parquet Rolling Sink", color: "#059669", icon: "file", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "path", type: "string", label: "Directory Path", default: "/data/parquet" }] },
{ category: "sink", type: "otlp", label: "OpenTelemetry OTLP", color: "#059669", icon: "activity", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "endpoint", type: "string", label: "Endpoint", default: "http://127.0.0.1:4318/v1/metrics" }] },
{ category: "sink", type: "websocket", label: "WebSocket Sink", color: "#059669", icon: "radio", inputs: 1, outputs: 0, multi_port: false, properties: [{ name: "addr", type: "string", label: "WS URL", default: "ws://127.0.0.1:9090/ws" }] },
{ category: "sink", type: "nop", label: "NOP Drop", color: "#64748b", icon: "zap", inputs: 1, outputs: 0, multi_port: false, properties: [] }
];
}
function setPaletteFilter(filter) {
studio.paletteFilter = filter;
document.querySelectorAll('.palette-chip').forEach(btn => {
btn.classList.toggle('active', btn.id === `chip-${filter}`);
});
const q = document.getElementById('palette-search')?.value || '';
filterPalette(q);
}
function renderPalette(items) {
const container = document.getElementById('palette-items-list');
if (!container) return;
const cats = {
source: { title: "Data Sources", items: [] },
operator: { title: "Stream Processors", items: [] },
sink: { title: "Data Sinks", items: [] }
};
const filter = studio.paletteFilter || 'all';
for (const item of items) {
const cat = item.category === 'source' ? 'source' : (item.category === 'sink' ? 'sink' : 'operator');
if (filter !== 'all' && cat !== filter) continue;
cats[cat].items.push(item);
}
let html = '';
for (const [k, group] of Object.entries(cats)) {
if (group.items.length === 0) continue;
html += `
<div class="palette-cat-header">
<span>${escapeHtml(group.title)}</span>
<span class="badge badge-slate" style="font-size: 10px; padding: 1px 6px;">${group.items.length}</span>
</div>
`;
for (const node of group.items) {
const catColor = getNodeCatColor(node.category, node.type);
const svgIcon = getStudioIconSvg(node.icon || node.type, node.category);
html += `
<div class="palette-card" draggable="true" ondragstart="event.dataTransfer.setData('text/plain', JSON.stringify({ type: '${node.type}', category: '${node.category}' }))">
<div class="palette-card-left">
<div class="palette-icon" style="background: ${catColor}15; color: ${catColor}; border: 1px solid ${catColor}30;">
${svgIcon}
</div>
<div>
<div class="palette-card-title">${escapeHtml(node.label || node.type)}</div>
<div class="palette-card-sub">${escapeHtml(node.type)}</div>
</div>
</div>
<button class="palette-add-btn" onclick="addNodeToCanvasCenter('${node.type}', '${node.category}')" title="Add to canvas">+</button>
</div>
`;
}
}
if (!html) {
html = `<div style="text-align: center; color: #64748b; padding: 28px 10px; font-size: 12px;">No components match filter</div>`;
}
container.innerHTML = html;
}
function filterPalette(query) {
const q = query.toLowerCase().trim();
const allItems = studio.palette && studio.palette.length > 0 ? studio.palette : getDefaultPaletteFallback();
if (!q) {
renderPalette(allItems);
return;
}
const filtered = allItems.filter(p =>
(p.label && p.label.toLowerCase().includes(q)) ||
(p.type && p.type.toLowerCase().includes(q)) ||
(p.description && p.description.toLowerCase().includes(q)) ||
(p.category && p.category.toLowerCase().includes(q))
);
renderPalette(filtered);
}
function addNodeToCanvasCenter(type, category) {
const container = document.getElementById('studio-canvas-container');
const w = container ? container.clientWidth : 800;
const h = container ? container.clientHeight : 500;
const x = Math.round((w / 2 - studio.pan.x) / studio.zoom - 120);
const y = Math.round((h / 2 - studio.pan.y) / studio.zoom - 44);
addNodeToCanvas(type, x, y, category);
}
function addNodeToCanvas(type, x, y, category) {
const allItems = studio.palette && studio.palette.length > 0 ? studio.palette : getDefaultPaletteFallback();
const meta = allItems.find(p => p.type === type && (category ? p.category === category : true)) ||
allItems.find(p => p.type === type) || {
type,
category: category || 'operator',
label: type,
color: '#6366f1',
inputs: 1,
outputs: 1,
multi_port: false,
properties: []
};
const finalCat = category || meta.category;
const catColor = getNodeCatColor(finalCat, meta.type);
const isSink = finalCat === 'sink';
const isSource = finalCat === 'source';
const nodeId = `${type}_${studio.nodeSeq++}`;
const props = {};
if (Array.isArray(meta.properties)) {
for (const p of meta.properties) {
props[p.name] = p.default !== undefined ? JSON.parse(JSON.stringify(p.default)) : '';
}
}
const node = {
id: nodeId,
type: meta.type,
category: finalCat,
label: meta.label || meta.type,
color: catColor,
icon: meta.icon || meta.type,
x: Math.max(x, 20),
y: Math.max(y, 20),
inputs: isSource ? 0 : (meta.inputs !== undefined ? meta.inputs : 1),
outputs: isSink ? 0 : (meta.outputs !== undefined ? meta.outputs : 1),
multi_port: meta.multi_port || (meta.type === 'switch'),
props,
metrics: { inTotal: 0, rate: 0 },
tapActive: false,
connectorStatus: 'connected'
};
studio.nodes.push(node);
renderNodeDom(node);
selectNode(node.id);
syncJsonFromCanvas();
updateNodeCountBadge();
}
function renderNodeDom(node) {
const layer = document.getElementById('studio-nodes-layer');
if (!layer) return;
const catColor = getNodeCatColor(node.category, node.type);
const div = document.createElement('div');
div.id = `node-${node.id}`;
div.className = `studio-node ${studio.selectedNodeId === node.id ? 'selected' : ''}`;
div.style.left = `${node.x}px`;
div.style.top = `${node.y}px`;
let bodySummary = '';
if (node.type === 'mqtt') bodySummary = `topic: ${node.props.datasource || node.props.topic || 'stream_in'}`;
else if (node.type === 'modbus') bodySummary = `reg: ${node.props.datasource || 'plc_data'}`;
else if (node.type === 'opcua') bodySummary = `ep: ${node.props.endpoint || 'opc.tcp://...'}`;
else if (node.type === 'switch') {
const cCount = Array.isArray(node.props.cases) ? node.props.cases.length : 2;
bodySummary = `branch: ${cCount} cases + default`;
}
else if (node.type === 'rate_limit') bodySummary = `throttle: ${node.props.rate_limit || 10} msg/s`;
else if (node.type === 'watchdog') bodySummary = `timeout: ${node.props.timeout_ms || 5000} ms`;
else if (node.type === 'filter') bodySummary = `expr: ${node.props.expr || 'temp > 25'}`;
else if (node.type === 'sql') {
const s = node.props.sql || 'SELECT *';
bodySummary = `sql: ${s.length > 26 ? s.slice(0, 26) + '...' : s}`;
}
else if (node.type === 'window') bodySummary = `window: ${node.props.window_type || 'tumbling'}`;
else if (node.type === 'log') bodySummary = 'sink: stdout log';
else if (node.type === 'rest') bodySummary = `url: ${node.props.url || '/ingest'}`;
else bodySummary = `${node.category} operator`;
let portsHtml = '';
if (node.multi_port && node.type === 'switch') {
const cases = Array.isArray(node.props.cases) ? node.props.cases : [ { case: "case0", port: 0 }, { case: "case1", port: 1 } ];
portsHtml += `<div class="node-ports-footer">`;
cases.forEach((c, idx) => {
const caseLabel = typeof c === 'object' ? (c.case || `case ${idx}`) : String(c);
portsHtml += `
<div class="node-port-row">
<span class="port-label" title="${escapeHtml(caseLabel)}">${escapeHtml(caseLabel)}</span>
<div class="port-dot output-port" data-node="${node.id}" data-port="${idx}" data-polarity="output" title="Port ${idx}: ${escapeHtml(caseLabel)}" onmousedown="startPortWire(event, '${node.id}', ${idx}, true)"></div>
</div>`;
});
const defPort = cases.length;
portsHtml += `
<div class="node-port-row">
<span class="port-label" style="color: #64748b;">Default fallback</span>
<div class="port-dot output-port" data-node="${node.id}" data-port="${defPort}" data-polarity="output" title="Default Port: ${defPort}" onmousedown="startPortWire(event, '${node.id}', ${defPort}, true)"></div>
</div>
</div>`;
} else {
if (node.outputs > 0) {
portsHtml += `<div class="port-dot output-port" data-node="${node.id}" data-port="0" data-polarity="output" title="Output Port" onmousedown="startPortWire(event, '${node.id}', 0, true)"></div>`;
}
}
if (node.inputs > 0) {
portsHtml += `<div class="port-dot input-port" data-node="${node.id}" data-port="0" data-polarity="input" title="Input Port" onmouseup="completePortWire(event, '${node.id}', 0)"></div>`;
}
const isStreaming = (node.metrics && node.metrics.rate > 0) || node.tapActive;
const svgIcon = getStudioIconSvg(node.icon || node.type, node.category);
div.innerHTML = `
<div class="node-accent-bar" style="background: ${catColor};"></div>
<div class="studio-node-header">
<div class="node-icon-wrapper" style="background: ${catColor}18; color: ${catColor}; border: 1px solid ${catColor}33;">
${svgIcon}
</div>
<div class="node-title-group" style="flex: 1; min-width: 0; margin: 0 8px;">
<div style="display: flex; align-items: center; gap: 6px;">
<div class="node-title">${escapeHtml(node.label || node.type)}</div>
<span class="node-tap-indicator" id="tap-indicator-${node.id}" style="display: ${node.tapActive ? 'inline-flex' : 'none'};">⚡ TAP</span>
</div>
<div class="node-type-sub">${escapeHtml(node.type)} · #${escapeHtml(node.id)}</div>
</div>
<div class="node-status-dot ${isStreaming ? 'streaming' : ''}" id="status-dot-${node.id}" title="${(node.metrics && node.metrics.rate) || 0} msgs/s"></div>
</div>
<div class="studio-node-body">
<div class="node-summary-pill" title="${escapeHtml(bodySummary)}">${escapeHtml(bodySummary)}</div>
<div class="node-throughput-badge" id="badge-${node.id}">
<span class="throughput-val">${(node.metrics && node.metrics.rate) || 0}</span> msg/s
</div>
</div>
${portsHtml}
`;
div.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('.port-dot') || e.target.closest('.port-handle')) return;
e.stopPropagation();
selectNode(node.id);
studio.draggingNodeId = node.id;
const container = document.getElementById('studio-canvas-container');
const cRect = container ? container.getBoundingClientRect() : { left: 0, top: 0 };
const mouseCanvasX = (e.clientX - cRect.left - studio.pan.x) / studio.zoom;
const mouseCanvasY = (e.clientY - cRect.top - studio.pan.y) / studio.zoom;
studio.nodeDragOffset = {
x: mouseCanvasX - node.x,
y: mouseCanvasY - node.y
};
});
layer.appendChild(div);
}
function updateNodeDomPosition(node) {
const el = document.getElementById(`node-${node.id}`);
if (el) {
el.style.left = `${node.x}px`;
el.style.top = `${node.y}px`;
}
}
function updateNodeCountBadge() {
const badge = document.getElementById('canvas-node-counter');
if (badge) {
badge.textContent = `${studio.nodes.length} nodes · ${studio.edges.length} wires`;
}
}
function selectNode(nodeId) {
studio.selectedNodeId = nodeId;
studio.selectedEdgeId = null;
document.querySelectorAll('.studio-node').forEach(el => {
el.classList.toggle('selected', el.id === `node-${nodeId}`);
});
document.querySelectorAll('.studio-wire').forEach(w => w.classList.remove('selected'));
const emptyView = document.getElementById('node-inspector-empty');
const contentView = document.getElementById('node-inspector-content');
if (!nodeId) {
if (emptyView) emptyView.style.display = 'block';
if (contentView) contentView.style.display = 'none';
return;
}
const node = studio.nodes.find(n => n.id === nodeId);
if (!node) return;
if (emptyView) emptyView.style.display = 'none';
if (contentView) contentView.style.display = 'block';
const catColor = getNodeCatColor(node.category, node.type);
const catBadge = document.getElementById('insp-node-category');
if (catBadge) {
catBadge.textContent = node.category.toUpperCase();
catBadge.className = `badge badge-${node.category === 'source' ? 'sky' : (node.category === 'sink' ? 'emerald' : 'indigo')}`;
}
const titleEl = document.getElementById('insp-node-title');
if (titleEl) {
titleEl.innerHTML = `
<span style="display: inline-flex; align-items: center; gap: 8px;">
<span style="display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 6px; background: ${catColor}20; color: ${catColor};">
${getStudioIconSvg(node.icon || node.type, node.category)}
</span>
<span>${escapeHtml(node.label || node.type)}</span>
</span>
`;
}
const idInput = document.getElementById('insp-node-id');
if (idInput) idInput.value = node.id;
renderNodePropsForm(node);
updateInspectorWireTap(node);
}
function renderNodePropsForm(node) {
const container = document.getElementById('insp-node-props-form');
if (!container) return;
let html = '';
if (node.type === 'sql') {
const currentSql = node.props.sql || 'SELECT * FROM src';
html += `
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label class="form-label" style="color: #94a3b8; font-weight: 600;">SQL Transformation Query</label>
<span class="badge badge-indigo" style="font-size: 9.5px;">Kuiper SQL Engine</span>
</div>
<textarea
id="sql-editor-textarea"
class="code-input"
rows="5"
style="font-family: var(--font-mono); font-size: 12px; width: 100%;"
onchange="updateNodeProp('sql', this.value, false)"
oninput="updateNodeProp('sql', this.value, false)"
>${escapeHtml(currentSql)}</textarea>
<div class="code-snippets" style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px;">
<button class="snippet-btn" onclick="insertSqlSnippet('SELECT *')">+ SELECT *</button>
<button class="snippet-btn" onclick="insertSqlSnippet('WHERE temperature > 75')">+ WHERE</button>
<button class="snippet-btn" onclick="insertSqlSnippet('TUMBLINGWINDOW(ss, 10)')">+ Tumbling 10s</button>
<button class="snippet-btn" onclick="insertSqlSnippet('ORDER BY ts DESC')">+ ORDER BY</button>
</div>
</div>
`;
} else if (node.type === 'switch') {
const cases = Array.isArray(node.props.cases) ? node.props.cases : [ { case: "temp > 50", port: 0 }, { case: "temp <= 50", port: 1 } ];
html += `
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label class="form-label" style="color: #94a3b8; font-weight: 600;">Dynamic Branch Routing Rules</label>
<button class="btn btn-sm" onclick="addSwitchBranch('${node.id}')" style="background: #1e293b; color: #fb923c; border: 1px solid #ea580c; font-size: 11px; padding: 2px 6px;">+ Add Branch</button>
</div>
<div id="switch-branches-list" style="display: flex; flex-direction: column; gap: 6px;">
`;
cases.forEach((c, idx) => {
const expr = typeof c === 'object' ? (c.case || '') : String(c);
html += `
<div style="display: flex; align-items: center; gap: 6px; background: #060911; padding: 6px; border: 1px solid #1e293b; border-radius: 6px;">
<span class="badge badge-orange" style="font-size: 10px; font-family: var(--font-mono); min-width: 48px; text-align: center;">Port ${idx}</span>
<input
type="text"
class="text-input"
style="flex: 1; font-family: var(--font-mono); font-size: 11.5px; padding: 4px 6px; background: #0f172a; color: #f8fafc; border-color: #23344d;"
value="${escapeHtml(expr)}"
placeholder="Expression (e.g. speed > 100)"
onchange="updateSwitchBranch(${idx}, this.value)"
/>
<button class="btn btn-sm" onclick="removeSwitchBranch(${idx})" style="background: rgba(239,68,68,0.15); color: #ef4444; border: none; padding: 4px 6px; border-radius: 4px;" title="Remove Branch">✕</button>
</div>
`;
});
html += `
<div style="display: flex; align-items: center; gap: 6px; background: #060911; padding: 6px; border: 1px dashed #334155; border-radius: 6px;">
<span class="badge badge-slate" style="font-size: 10px; font-family: var(--font-mono); min-width: 48px; text-align: center;">Port ${cases.length}</span>
<span style="flex: 1; font-size: 11.5px; color: #64748b; font-style: italic;">Default Fallback (catches all unmatched tuples)</span>
</div>
</div>
</div>
`;
} else if (node.type === 'rate_limit') {
const rate = node.props.rate_limit || 10;
html += `
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label class="form-label" style="color: #94a3b8; font-weight: 600;">Max Message Rate Limit</label>
<span id="rate-limit-val" style="font-family: var(--font-mono); font-size: 12px; color: #38bdf8; font-weight: 700;">${rate} msg/s</span>
</div>
<input
type="range"
min="1"
max="200"
value="${rate}"
style="width: 100%; accent-color: #0284c7; margin-bottom: 6px;"
oninput="document.getElementById('rate-limit-val').textContent = this.value + ' msg/s'; updateNodeProp('rate_limit', Number(this.value), false)"
/>
</div>
`;
} else if (node.type === 'watchdog') {
const timeout = node.props.timeout_ms || 5000;
html += `
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label class="form-label" style="color: #94a3b8; font-weight: 600;">Inactivity Timeout (ms)</label>
<span id="watchdog-val" style="font-family: var(--font-mono); font-size: 12px; color: #f43f5e; font-weight: 700;">${timeout} ms (${timeout / 1000}s)</span>
</div>
<input
type="range"
min="500"
max="30000"
step="500"
value="${timeout}"
style="width: 100%; accent-color: #e11d48; margin-bottom: 6px;"
oninput="document.getElementById('watchdog-val').textContent = this.value + ' ms (' + (this.value/1000) + 's)'; updateNodeProp('timeout_ms', Number(this.value), false)"
/>
</div>
`;
}
for (const [key, val] of Object.entries(node.props)) {
if (node.type === 'sql' && key === 'sql') continue;
if (node.type === 'switch' && key === 'cases') continue;
if (node.type === 'rate_limit' && key === 'rate_limit') continue;
if (node.type === 'watchdog' && key === 'timeout_ms') continue;
const valStr = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
const isJson = typeof val === 'object';
html += `
<div class="form-group">
<label class="form-label" style="color: #94a3b8; font-family: var(--font-mono); font-size: 11px;">${escapeHtml(key)}</label>
${isJson
? `<textarea class="text-input" style="background: #060911; color: #f8fafc; border-color: #1e293b; font-family: var(--font-mono); min-height: 80px; font-size: 11.5px;" onchange="updateNodeProp('${key}', this.value, true)">${escapeHtml(valStr)}</textarea>`
: `<input type="text" class="text-input" style="background: #060911; color: #f8fafc; border-color: #1e293b; font-family: var(--font-mono); font-size: 12px;" value="${escapeHtml(valStr)}" onchange="updateNodeProp('${key}', this.value, false)" />`
}
</div>
`;
}
container.innerHTML = html;
}
function insertSqlSnippet(snippet) {
const textarea = document.getElementById('sql-editor-textarea');
if (!textarea) return;
textarea.value = textarea.value.trim() + ' ' + snippet;
updateNodeProp('sql', textarea.value, false);
}
function addSwitchBranch(nodeId) {
const node = studio.nodes.find(n => n.id === nodeId);
if (!node) return;
if (!Array.isArray(node.props.cases)) node.props.cases = [];
const newIdx = node.props.cases.length;
node.props.cases.push({ case: `status == 'ALERT_${newIdx + 1}'`, port: newIdx });
node.outputs = node.props.cases.length + 1;
const domNode = document.getElementById(`node-${node.id}`);
if (domNode) {
domNode.remove();
renderNodeDom(node);
}
renderNodePropsForm(node);
updateWirePaths();
syncJsonFromCanvas();
}
function updateSwitchBranch(idx, newExpr) {
if (!studio.selectedNodeId) return;
const node = studio.nodes.find(n => n.id === studio.selectedNodeId);
if (!node || !Array.isArray(node.props.cases) || !node.props.cases[idx]) return;
node.props.cases[idx].case = newExpr;
const domNode = document.getElementById(`node-${node.id}`);
if (domNode) {
domNode.remove();
renderNodeDom(node);
}
updateWirePaths();
syncJsonFromCanvas();
}
function removeSwitchBranch(idx) {
if (!studio.selectedNodeId) return;
const node = studio.nodes.find(n => n.id === studio.selectedNodeId);
if (!node || !Array.isArray(node.props.cases) || !node.props.cases[idx]) return;
node.props.cases.splice(idx, 1);
node.props.cases.forEach((c, i) => { c.port = i; });
node.outputs = node.props.cases.length + 1;
studio.edges = studio.edges.filter(e => !(e.fromNode === node.id && e.fromPort === idx));
const domNode = document.getElementById(`node-${node.id}`);
if (domNode) {
domNode.remove();
renderNodeDom(node);
}
renderNodePropsForm(node);
updateWirePaths();
syncJsonFromCanvas();
}
function updateNodeProp(key, rawVal, isJson) {
if (!studio.selectedNodeId) return;
const node = studio.nodes.find(n => n.id === studio.selectedNodeId);
if (!node) return;
try {
node.props[key] = isJson ? JSON.parse(rawVal) : (isNaN(Number(rawVal)) || rawVal.trim() === '' ? rawVal : Number(rawVal));
} catch (e) {
node.props[key] = rawVal;
}
const domNode = document.getElementById(`node-${node.id}`);
if (domNode) {
domNode.remove();
renderNodeDom(node);
}
updateWirePaths();
syncJsonFromCanvas();
}
function updateSelectedNodeId(newId) {
const cleanId = newId.trim();
if (!cleanId || !studio.selectedNodeId) return;
const oldId = studio.selectedNodeId;
if (oldId === cleanId) return;
if (studio.nodes.some(n => n.id === cleanId)) {
showToast(`Node ID '${cleanId}' already exists`, 'error');
document.getElementById('insp-node-id').value = oldId;
return;
}
const node = studio.nodes.find(n => n.id === oldId);
if (!node) return;
node.id = cleanId;
studio.selectedNodeId = cleanId;
for (const e of studio.edges) {
if (e.fromNode === oldId) e.fromNode = cleanId;
if (e.toNode === oldId) e.toNode = cleanId;
}
renderAllNodes();
updateWirePaths();
selectNode(cleanId);
syncJsonFromCanvas();
}
function deleteSelectedNode() {
if (!studio.selectedNodeId) return;
const id = studio.selectedNodeId;
studio.nodes = studio.nodes.filter(n => n.id !== id);
studio.edges = studio.edges.filter(e => e.fromNode !== id && e.toNode !== id);
const el = document.getElementById(`node-${id}`);
if (el) el.remove();
updateWirePaths();
selectNode(null);
syncJsonFromCanvas();
updateNodeCountBadge();
showToast(`Deleted node '${id}'`, 'success');
}
function startPortWire(e, nodeId, portIndex, isOutput) {
e.stopPropagation();
studio.wireDrag = {
fromNodeId: nodeId,
fromPort: portIndex,
isOutput: isOutput
};
const path = document.getElementById('studio-drag-wire');
if (path) {
path.style.display = 'block';
}
}
function updateDragWire(curX, curY) {
const path = document.getElementById('studio-drag-wire');
if (!path || !studio.wireDrag) return;
const fromPt = getPortCoords(studio.wireDrag.fromNodeId, studio.wireDrag.fromPort, studio.wireDrag.isOutput);
const x1 = fromPt.x;
const y1 = fromPt.y;
const x2 = curX;
const y2 = curY;
const dx = Math.max(Math.abs(x2 - x1) * 0.5, 45);
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`);
}
function completePortWire(e, targetNodeId, targetPortIndex) {
e.stopPropagation();
if (!studio.wireDrag) return;
const srcId = studio.wireDrag.fromNodeId;
const srcPort = studio.wireDrag.fromPort;
cancelWireDrag();
if (srcId === targetNodeId) return;
const edgeExists = studio.edges.some(e => e.fromNode === srcId && e.fromPort === srcPort && e.toNode === targetNodeId);
if (edgeExists) return;
const edgeId = `edge_${srcId}_p${srcPort}_to_${targetNodeId}`;
studio.edges.push({
id: edgeId,
fromNode: srcId,
fromPort: srcPort,
toNode: targetNodeId,
toPort: targetPortIndex || 0
});
updateWirePaths();
syncJsonFromCanvas();
updateNodeCountBadge();
}
function cancelWireDrag() {
studio.wireDrag = null;
const path = document.getElementById('studio-drag-wire');
if (path) path.style.display = 'none';
}
function getPortCoords(nodeId, portIndex, isOutput) {
const node = studio.nodes.find(n => n.id === nodeId);
if (!node) return { x: 0, y: 0 };
const el = document.getElementById(`node-${nodeId}`);
if (el) {
const portSelector = isOutput
? (node.multi_port ? `.output-port[data-port="${portIndex}"]` : '.output-port')
: '.input-port';
const dot = el.querySelector(portSelector);
if (dot) {
const elRect = el.getBoundingClientRect();
const dotRect = dot.getBoundingClientRect();
const x = isOutput ? node.x + 240 : node.x;
const y = node.y + (dotRect.top - elRect.top) + (dotRect.height / 2);
return { x, y };
}
}
const nodeW = 240;
let x = isOutput ? node.x + nodeW : node.x;
let y = node.y + 44;
if (isOutput && node.multi_port && node.type === 'switch') {
const rowHeight = 24;
const baseOffset = 88;
y = node.y + baseOffset + (portIndex * rowHeight) + 12;
}
return { x, y };
}
function updateWirePaths() {
const group = document.getElementById('studio-wires-group');
if (!group) return;
let html = '';
for (const edge of studio.edges) {
const fromPt = getPortCoords(edge.fromNode, edge.fromPort, true);
const toPt = getPortCoords(edge.toNode, edge.toPort, false);
const dx = Math.max(Math.abs(toPt.x - fromPt.x) * 0.5, 45);
const d = `M ${fromPt.x} ${fromPt.y} C ${fromPt.x + dx} ${fromPt.y}, ${toPt.x - dx} ${toPt.y}, ${toPt.x} ${toPt.y}`;
const isSelected = studio.selectedEdgeId === edge.id;
const marker = isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)';
html += `
<path
class="studio-wire-hit"
d="${d}"
stroke="transparent"
stroke-width="16"
fill="none"
style="cursor: pointer;"
onclick="selectEdge('${edge.id}')"
/>
`;
if (isSelected) {
html += `
<path
d="${d}"
stroke="rgba(249, 115, 22, 0.4)"
stroke-width="7"
stroke-linecap="round"
fill="none"
/>
`;
}
html += `
<path
id="${edge.id}"
class="studio-wire ${isSelected ? 'selected' : ''}"
d="${d}"
marker-end="${marker}"
onclick="selectEdge('${edge.id}')"
>
<title>${edge.fromNode} [p${edge.fromPort}] ➔ ${edge.toNode}</title>
</path>
`;
html += `
<circle r="3" fill="${isSelected ? '#fdba74' : '#f97316'}" opacity="0.85">
<animateMotion dur="2.4s" repeatCount="indefinite" path="${d}" />
</circle>
`;
}
group.innerHTML = html;
}
function selectEdge(edgeId) {
studio.selectedEdgeId = edgeId;
updateWirePaths();
}
function deleteSelectedEdge() {
if (!studio.selectedEdgeId) return;
studio.edges = studio.edges.filter(e => e.id !== studio.selectedEdgeId);
studio.selectedEdgeId = null;
updateWirePaths();
syncJsonFromCanvas();
updateNodeCountBadge();
}
function updateStudioThroughputFromRule(ruleId) {
if (!ruleId || !studio.nodes || studio.nodes.length === 0) return;
const inRate = (state.rates && state.rates.perRuleIn && state.rates.perRuleIn[ruleId]) || 0;
const outRate = (state.rates && state.rates.perRuleOut && state.rates.perRuleOut[ruleId]) || 0;
const m = (state.ruleMetrics && state.ruleMetrics[ruleId]) || {};
const sumIn = extractMetricSum(m, 'records_in_total');
const sumOut = extractMetricSum(m, 'records_out_total');
for (const node of studio.nodes) {
if (node.category === 'source') {
node.metrics.rate = inRate;
node.metrics.inTotal = sumIn;
} else if (node.category === 'sink') {
node.metrics.rate = outRate;
node.metrics.inTotal = sumOut;
} else {
node.metrics.rate = inRate;
node.metrics.inTotal = sumIn;
}
const badge = document.getElementById(`badge-${node.id}`);
if (badge) {
badge.innerHTML = `<span class="throughput-val">${node.metrics.rate}</span> msg/s`;
}
const statusDot = document.getElementById(`status-dot-${node.id}`);
if (statusDot) {
statusDot.classList.toggle('streaming', node.metrics.rate > 0 || node.tapActive);
statusDot.title = `${node.metrics.rate} msgs/s (${node.metrics.inTotal} total)`;
}
}
}
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'Delete' || e.key === 'Backspace') {
if (studio.selectedEdgeId) deleteSelectedEdge();
else if (studio.selectedNodeId) deleteSelectedNode();
}
});
function autoLayoutNodes() {
if (studio.nodes.length === 0) return;
const layers = { sources: [], operators: [], sinks: [] };
for (const n of studio.nodes) {
if (n.category === 'source') layers.sources.push(n);
else if (n.category === 'sink') layers.sinks.push(n);
else layers.operators.push(n);
}
const colWidth = 330;
const rowHeight = 125;
const startX = 60;
const startY = 60;
layers.sources.forEach((n, idx) => {
n.x = startX;
n.y = startY + (idx * (rowHeight + 30));
updateNodeDomPosition(n);
});
layers.operators.forEach((n, idx) => {
n.x = startX + colWidth;
n.y = startY + (idx * (rowHeight + 40));
updateNodeDomPosition(n);
});
const sinkCol = layers.operators.length > 0 ? startX + (colWidth * 2) : startX + colWidth;
layers.sinks.forEach((n, idx) => {
n.x = sinkCol;
n.y = startY + (idx * (rowHeight + 30));
updateNodeDomPosition(n);
});
resetCanvasTransform();
updateWirePaths();
syncJsonFromCanvas();
showToast('Nodes auto-aligned into DAG columns', 'success');
}
function clearStudioCanvas() {
studio.nodes = [];
studio.edges = [];
studio.selectedNodeId = null;
studio.selectedEdgeId = null;
const layer = document.getElementById('studio-nodes-layer');
if (layer) layer.innerHTML = '';
updateWirePaths();
selectNode(null);
syncJsonFromCanvas();
updateNodeCountBadge();
showToast('Canvas cleared', 'success');
}
function renderAllNodes() {
const layer = document.getElementById('studio-nodes-layer');
if (!layer) return;
layer.innerHTML = '';
for (const node of studio.nodes) {
renderNodeDom(node);
}
updateWirePaths();
updateNodeCountBadge();
}
function serializeGraphToJson() {
const nodesObj = {};
const sourcesList = [];
const edgesObj = {};
for (const node of studio.nodes) {
nodesObj[node.id] = {
type: node.type,
node_type: node.category,
props: node.props
};
if (node.category === 'source') {
sourcesList.push(node.id);
}
}
for (const node of studio.nodes) {
const outEdges = studio.edges.filter(e => e.fromNode === node.id);
if (outEdges.length === 0) continue;
if (node.multi_port && node.type === 'switch') {
const cases = Array.isArray(node.props.cases) ? node.props.cases : [0, 1];
const numPorts = cases.length + 1; const portBuckets = Array.from({ length: numPorts }, () => []);
for (const edge of outEdges) {
const p = edge.fromPort || 0;
if (p < numPorts) {
portBuckets[p].push(edge.toNode);
}
}
edgesObj[node.id] = portBuckets;
} else {
if (outEdges.length === 1) {
edgesObj[node.id] = outEdges[0].toNode;
} else {
edgesObj[node.id] = [ outEdges.map(e => e.toNode) ];
}
}
}
return {
id: studio.ruleId,
graph: {
nodes: nodesObj,
topo: {
sources: sourcesList,
edges: edgesObj
}
}
};
}
function syncJsonFromCanvas() {
const editor = document.getElementById('studio-json-editor');
if (!editor) return;
const graphDef = serializeGraphToJson();
editor.value = JSON.stringify(graphDef, null, 2);
}
function formatStudioJson() {
const editor = document.getElementById('studio-json-editor');
if (!editor) return;
try {
const parsed = JSON.parse(editor.value);
editor.value = JSON.stringify(parsed, null, 2);
} catch (e) {
showToast(`JSON Syntax Error: ${e.message}`, 'error');
}
}
function applyJsonToCanvas() {
const editor = document.getElementById('studio-json-editor');
if (!editor) return;
try {
const parsed = JSON.parse(editor.value);
if (!parsed || !parsed.graph || !parsed.graph.nodes) {
showToast('Invalid GraphRule format: missing graph.nodes', 'error');
return;
}
studio.ruleId = parsed.id || 'stream_pipeline_01';
const ruleIdInput = document.getElementById('studio-rule-id');
if (ruleIdInput) ruleIdInput.value = studio.ruleId;
studio.nodes = [];
studio.edges = [];
const rawNodes = parsed.graph.nodes;
for (const [id, def] of Object.entries(rawNodes)) {
const type = def.type || 'sql';
const meta = studio.palette.find(p => p.type === type) || {
type,
category: def.node_type || 'operator',
label: type,
color: '#6366f1',
inputs: 1,
outputs: 1,
multi_port: false
};
const cat = def.node_type || meta.category;
const nodeColor = getNodeCatColor(cat, type);
studio.nodes.push({
id,
type,
category: cat,
label: meta.label || type,
color: nodeColor,
icon: meta.icon || type,
x: 60,
y: 60,
inputs: meta.inputs !== undefined ? meta.inputs : 1,
outputs: meta.outputs !== undefined ? meta.outputs : 1,
multi_port: meta.multi_port || (type === 'switch'),
props: def.props || {},
metrics: { inTotal: 0, rate: 0 },
tapActive: false,
connectorStatus: 'connected'
});
}
const rawEdges = (parsed.graph.topo && parsed.graph.topo.edges) ? parsed.graph.topo.edges : {};
for (const [fromNode, target] of Object.entries(rawEdges)) {
if (typeof target === 'string') {
studio.edges.push({
id: `edge_${fromNode}_to_${target}`,
fromNode,
fromPort: 0,
toNode: target,
toPort: 0
});
} else if (Array.isArray(target)) {
if (target.length > 0 && Array.isArray(target[0])) {
target.forEach((portTargets, pIdx) => {
if (Array.isArray(portTargets)) {
for (const t of portTargets) {
studio.edges.push({
id: `edge_${fromNode}_p${pIdx}_to_${t}`,
fromNode,
fromPort: pIdx,
toNode: t,
toPort: 0
});
}
}
});
} else {
for (const t of target) {
if (typeof t === 'string') {
studio.edges.push({
id: `edge_${fromNode}_to_${t}`,
fromNode,
fromPort: 0,
toNode: t,
toPort: 0
});
}
}
}
}
}
renderAllNodes();
autoLayoutNodes();
updateStudioThroughputFromRule(studio.ruleId);
showToast('Applied JSON to DAG canvas', 'success');
} catch (e) {
showToast(`Failed to parse GraphRule JSON: ${e.message}`, 'error');
}
}
function validateStudioGraph() {
if (studio.nodes.length === 0) {
showToast('Validation failed: Canvas contains 0 nodes', 'error');
return false;
}
const hasSource = studio.nodes.some(n => n.category === 'source');
const hasSink = studio.nodes.some(n => n.category === 'sink');
if (!hasSource) {
showToast('Validation warning: Pipeline has no Source node', 'error');
return false;
}
if (!hasSink) {
showToast('Validation warning: Pipeline has no Sink node', 'error');
return false;
}
showToast(`Topology valid: ${studio.nodes.length} nodes, ${studio.edges.length} wires`, 'success');
return true;
}
async function deployStudioPipeline() {
if (!validateStudioGraph()) return;
const graphDef = serializeGraphToJson();
const ruleId = studio.ruleId;
const ruleExists = Array.isArray(state.rules) && state.rules.some(r => r.id === ruleId);
const method = ruleExists ? 'PUT' : 'POST';
const endpoint = ruleExists ? `/rules/${encodeURIComponent(ruleId)}` : '/rules';
try {
const res = await apiFetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(graphDef)
});
if (res.ok) {
showToast(`🚀 Rule '${ruleId}' ${ruleExists ? 'updated (PUT)' : 'deployed (POST)'} successfully`, 'success');
await refreshAll();
} else {
const msg = res.body && res.body.message ? res.body.message : `HTTP ${res.status}`;
showToast(`Deployment error: ${msg}`, 'error');
}
} catch (e) {
showToast(`Deploy request failed: ${e.message}`, 'error');
}
}
async function loadRuleIntoStudio(ruleId) {
if (!ruleId) return;
initStudioIfNeeded();
switchTab('studio');
try {
const res = await apiFetch(`/rules/${encodeURIComponent(ruleId)}`);
if (!res.ok || !res.body) {
showToast(`Failed to load rule '${ruleId}': HTTP ${res.status}`, 'error');
return;
}
const ruleDef = res.body;
studio.ruleId = ruleDef.id || ruleId;
const ruleIdInput = document.getElementById('studio-rule-id');
if (ruleIdInput) ruleIdInput.value = studio.ruleId;
if (ruleDef.graph && ruleDef.graph.nodes) {
const editor = document.getElementById('studio-json-editor');
if (editor) editor.value = JSON.stringify(ruleDef, null, 2);
applyJsonToCanvas();
showToast(`Loaded GraphRule '${ruleId}' into Studio`, 'success');
} else {
clearStudioCanvas();
studio.ruleId = ruleDef.id || ruleId;
if (ruleIdInput) ruleIdInput.value = studio.ruleId;
const sql = ruleDef.sql || '';
const fromMatch = sql.match(/\bFROM\s+([a-zA-Z0-9_]+)/i);
const streamName = fromMatch ? fromMatch[1] : 'stream_input';
const srcId = `${streamName}_source`;
addNodeToCanvas('mqtt', 80, 120);
const srcNode = studio.nodes[studio.nodes.length - 1];
srcNode.id = srcId;
srcNode.props.datasource = streamName;
srcNode.props.topic = streamName;
const sqlId = 'sql_processor';
addNodeToCanvas('sql', 380, 120);
const sqlNode = studio.nodes[studio.nodes.length - 1];
sqlNode.id = sqlId;
sqlNode.props.sql = sql;
studio.edges.push({
id: `edge_${srcId}_to_${sqlId}`,
fromNode: srcId,
fromPort: 0,
toNode: sqlId,
toPort: 0
});
const actions = Array.isArray(ruleDef.actions) ? ruleDef.actions : [];
if (actions.length > 0) {
actions.forEach((act, idx) => {
const actKey = Object.keys(act)[0] || 'log';
const actProps = act[actKey] || {};
const sinkId = `${actKey}_sink_${idx + 1}`;
const sinkType = (actKey === 'mqtt' || actKey === 'rest' || actKey === 'log' || actKey === 'kafka') ? actKey : 'rest';
addNodeToCanvas(sinkType, 680, 80 + idx * 100);
const sinkNode = studio.nodes[studio.nodes.length - 1];
sinkNode.id = sinkId;
sinkNode.props = Object.assign({}, actProps);
studio.edges.push({
id: `edge_${sqlId}_to_${sinkId}`,
fromNode: sqlId,
fromPort: 0,
toNode: sinkId,
toPort: 0
});
});
} else {
const sinkId = 'log_sink';
addNodeToCanvas('log', 680, 120);
const sinkNode = studio.nodes[studio.nodes.length - 1];
sinkNode.id = sinkId;
studio.edges.push({
id: `edge_${sqlId}_to_${sinkId}`,
fromNode: sqlId,
fromPort: 0,
toNode: sinkId,
toPort: 0
});
}
renderAllNodes();
autoLayoutNodes();
syncJsonFromCanvas();
updateStudioThroughputFromRule(ruleId);
showToast(`Constructed DAG for SQL Rule '${ruleId}' in Studio`, 'success');
}
} catch (e) {
showToast(`Error loading rule into Studio: ${e.message}`, 'error');
}
}
function switchDrawerTab(tab) {
document.querySelectorAll('.drawer-tab').forEach(b => b.classList.toggle('active', b.id === `drawer-tab-${tab}`));
document.querySelectorAll('.drawer-pane').forEach(p => p.classList.toggle('active', p.id === `drawer-pane-${tab}`));
if (tab === 'json') {
syncJsonFromCanvas();
}
}
function loadStudioPreset(presetKey) {
if (!presetKey) return;
clearStudioCanvas();
if (presetKey === 'iot_switch') {
studio.ruleId = 'iiot_switch_pipeline';
document.getElementById('studio-rule-id').value = studio.ruleId;
addNodeToCanvas('mqtt', 60, 100, 'source');
addNodeToCanvas('switch', 340, 90, 'operator');
addNodeToCanvas('mqtt', 680, 50, 'sink');
addNodeToCanvas('log', 680, 200, 'sink');
const [src, sw, snk1, snk2] = studio.nodes;
src.id = 'mqtt_plc_in';
src.props.datasource = 'factory/turbines';
sw.id = 'switch_router';
sw.props.cases = [
{ case: "temperature > 75", port: 0 },
{ case: "temperature <= 75", port: 1 }
];
snk1.id = 'alert_sink';
snk1.props.topic = 'alarms/high_temp';
snk2.id = 'audit_log';
studio.edges = [
{ id: 'e1', fromNode: 'mqtt_plc_in', fromPort: 0, toNode: 'switch_router', toPort: 0 },
{ id: 'e2', fromNode: 'switch_router', fromPort: 0, toNode: 'alert_sink', toPort: 0 },
{ id: 'e3', fromNode: 'switch_router', fromPort: 1, toNode: 'audit_log', toPort: 0 }
];
} else if (presetKey === 'rate_limited') {
studio.ruleId = 'rate_limited_plc';
document.getElementById('studio-rule-id').value = studio.ruleId;
addNodeToCanvas('modbus', 60, 100, 'source');
addNodeToCanvas('rate_limit', 340, 100, 'operator');
addNodeToCanvas('filter', 620, 100, 'operator');
addNodeToCanvas('rest', 900, 100, 'sink');
const [src, rl, flt, snk] = studio.nodes;
src.id = 'modbus_feed';
rl.id = 'token_bucket_throttle';
rl.props.rate_limit = 10.0;
flt.id = 'pressure_check';
flt.props.expr = 'pressure > 100';
snk.id = 'rest_webhook';
studio.edges = [
{ id: 'e1', fromNode: src.id, fromPort: 0, toNode: rl.id, toPort: 0 },
{ id: 'e2', fromNode: rl.id, fromPort: 0, toNode: flt.id, toPort: 0 },
{ id: 'e3', fromNode: flt.id, fromPort: 0, toNode: snk.id, toPort: 0 }
];
} else if (presetKey === 'watchdog_alarm') {
studio.ruleId = 'watchdog_inactivity_rule';
document.getElementById('studio-rule-id').value = studio.ruleId;
addNodeToCanvas('mqtt', 60, 100, 'source');
addNodeToCanvas('watchdog', 340, 100, 'operator');
addNodeToCanvas('rest', 620, 100, 'sink');
const [src, wd, snk] = studio.nodes;
src.id = 'heartbeat_in';
src.props.datasource = 'edge/ping';
wd.id = 'silence_detector';
wd.props.timeout_ms = 5000;
snk.id = 'pagerduty_webhook';
studio.edges = [
{ id: 'e1', fromNode: src.id, fromPort: 0, toNode: wd.id, toPort: 0 },
{ id: 'e2', fromNode: wd.id, fromPort: 0, toNode: snk.id, toPort: 0 }
];
} else if (presetKey === 'window_agg') {
studio.ruleId = 'windowed_telemetry_agg';
document.getElementById('studio-rule-id').value = studio.ruleId;
addNodeToCanvas('simulator', 60, 100, 'source');
addNodeToCanvas('window', 340, 100, 'operator');
addNodeToCanvas('sql', 620, 100, 'operator');
addNodeToCanvas('parquet', 900, 100, 'sink');
const [src, win, sql, snk] = studio.nodes;
src.id = 'sim_sensor';
win.id = 'tumbling_window';
win.props.window_type = 'tumbling';
win.props.length = 10000;
sql.id = 'calc_avg';
sql.props.sql = 'SELECT avg(temperature) AS avg_temp FROM sim_sensor';
snk.id = 'parquet_file';
studio.edges = [
{ id: 'e1', fromNode: src.id, fromPort: 0, toNode: win.id, toPort: 0 },
{ id: 'e2', fromNode: win.id, fromPort: 0, toNode: sql.id, toPort: 0 },
{ id: 'e3', fromNode: sql.id, fromPort: 0, toNode: snk.id, toPort: 0 }
];
}
renderAllNodes();
autoLayoutNodes();
if (studio.nodes.length > 0) selectNode(studio.nodes[0].id);
showToast(`Loaded template '${presetKey}'`, 'success');
}
function synthesizeGraphFromPrompt() {
const input = document.getElementById('studio-ai-prompt');
const prompt = input ? input.value.trim() : '';
if (!prompt) {
showToast('Please enter a natural language pipeline description', 'error');
return;
}
const pLower = prompt.toLowerCase();
clearStudioCanvas();
studio.ruleId = 'ai_synthesized_flow';
const ruleIdInput = document.getElementById('studio-rule-id');
if (ruleIdInput) ruleIdInput.value = studio.ruleId;
let srcType = 'mqtt';
let srcTopic = 'factory/telemetry';
if (pLower.includes('modbus') || pLower.includes('plc')) {
srcType = 'modbus';
} else if (pLower.includes('opcua') || pLower.includes('opc-ua')) {
srcType = 'opcua';
} else if (pLower.includes('kafka')) {
srcType = 'kafka';
} else if (pLower.includes('http') || pLower.includes('pull') || pLower.includes('rest')) {
srcType = 'httppull';
}
addNodeToCanvas(srcType, 60, 100, 'source');
const srcNode = studio.nodes[0];
srcNode.id = `${srcType}_source`;
if (srcType === 'mqtt') srcNode.props.datasource = srcTopic;
let lastNodeId = srcNode.id;
if (pLower.includes('rate limit') || pLower.includes('throttle') || pLower.includes('token')) {
addNodeToCanvas('rate_limit', 340, 100, 'operator');
const rlNode = studio.nodes[studio.nodes.length - 1];
rlNode.id = 'rate_limiter';
const m = pLower.match(/(\d+)\s*(?:msg|msgs|rec|tuples)?(?:\s*\/\s*|\s*per\s*)s/);
if (m) rlNode.props.rate_limit = Number(m[1]);
studio.edges.push({ id: `e_${lastNodeId}_to_${rlNode.id}`, fromNode: lastNodeId, fromPort: 0, toNode: rlNode.id, toPort: 0 });
lastNodeId = rlNode.id;
}
if (pLower.includes('watchdog') || pLower.includes('timeout') || pLower.includes('inactivity')) {
addNodeToCanvas('watchdog', 340, 100, 'operator');
const wdNode = studio.nodes[studio.nodes.length - 1];
wdNode.id = 'watchdog_monitor';
const m = pLower.match(/(\d+)\s*(?:s|sec|seconds)/);
if (m) wdNode.props.timeout_ms = Number(m[1]) * 1000;
studio.edges.push({ id: `e_${lastNodeId}_to_${wdNode.id}`, fromNode: lastNodeId, fromPort: 0, toNode: wdNode.id, toPort: 0 });
lastNodeId = wdNode.id;
}
if (pLower.includes('window') || pLower.includes('tumbling') || pLower.includes('aggregate')) {
addNodeToCanvas('window', 340, 100, 'operator');
const winNode = studio.nodes[studio.nodes.length - 1];
winNode.id = 'tumbling_window';
studio.edges.push({ id: `e_${lastNodeId}_to_${winNode.id}`, fromNode: lastNodeId, fromPort: 0, toNode: winNode.id, toPort: 0 });
lastNodeId = winNode.id;
}
const isSwitch = pLower.includes('switch') || pLower.includes('route') || pLower.includes('branch') || (pLower.includes('else') && pLower.includes('>'));
if (isSwitch) {
addNodeToCanvas('switch', 400, 100, 'operator');
const swNode = studio.nodes[studio.nodes.length - 1];
swNode.id = 'switch_router';
let predicate = "temperature > 70";
const predMatch = prompt.match(/([a-zA-Z_0-9]+)\s*(>|<|>=|<=|==|=)\s*(\d+(?:\.\d+)?)/);
if (predMatch) {
predicate = `${predMatch[1]} ${predMatch[2]} ${predMatch[3]}`;
}
swNode.props.cases = [
{ case: predicate, port: 0 },
{ case: `NOT (${predicate})`, port: 1 }
];
studio.edges.push({ id: `e_${lastNodeId}_to_${swNode.id}`, fromNode: lastNodeId, fromPort: 0, toNode: swNode.id, toPort: 0 });
addNodeToCanvas('mqtt', 700, 50, 'sink');
const alertSink = studio.nodes[studio.nodes.length - 1];
alertSink.id = 'critical_alerts';
alertSink.props.topic = 'alerts/critical';
const normSinkType = pLower.includes('log') ? 'log' : (pLower.includes('timescale') || pLower.includes('rest') ? 'rest' : 'mqtt');
addNodeToCanvas(normSinkType, 700, 180, 'sink');
const normalSink = studio.nodes[studio.nodes.length - 1];
normalSink.id = 'normal_telemetry';
studio.edges.push({ id: 'e_sw_p0', fromNode: swNode.id, fromPort: 0, toNode: alertSink.id, toPort: 0 });
studio.edges.push({ id: 'e_sw_p1', fromNode: swNode.id, fromPort: 1, toNode: normalSink.id, toPort: 0 });
} else {
if (pLower.includes('filter') || pLower.includes('where') || pLower.includes('>') || pLower.includes('<')) {
addNodeToCanvas('filter', 400, 100, 'operator');
const fltNode = studio.nodes[studio.nodes.length - 1];
fltNode.id = 'filter_eval';
const predMatch = prompt.match(/([a-zA-Z_0-9]+)\s*(>|<|>=|<=|==|=)\s*(\d+(?:\.\d+)?)/);
if (predMatch) fltNode.props.expr = `${predMatch[1]} ${predMatch[2]} ${predMatch[3]}`;
studio.edges.push({ id: `e_${lastNodeId}_to_${fltNode.id}`, fromNode: lastNodeId, fromPort: 0, toNode: fltNode.id, toPort: 0 });
lastNodeId = fltNode.id;
}
let sinkType = 'mqtt';
if (pLower.includes('kafka')) sinkType = 'kafka';
else if (pLower.includes('rest') || pLower.includes('webhook') || pLower.includes('http')) sinkType = 'rest';
else if (pLower.includes('log')) sinkType = 'log';
else if (pLower.includes('file')) sinkType = 'file';
addNodeToCanvas(sinkType, 700, 100, 'sink');
const sinkNode = studio.nodes[studio.nodes.length - 1];
sinkNode.id = `${sinkType}_sink`;
studio.edges.push({ id: `e_${lastNodeId}_to_${sinkNode.id}`, fromNode: lastNodeId, fromPort: 0, toNode: sinkNode.id, toPort: 0 });
}
renderAllNodes();
autoLayoutNodes();
if (studio.nodes.length > 0) selectNode(studio.nodes[0].id);
showToast(`✨ Synthesized ${studio.nodes.length}-node pipeline from prompt`, 'success');
}
function initCommsWebSocket() {
if (studio.commsWs) return;
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${window.location.host}/comms`;
try {
const ws = new WebSocket(wsUrl);
studio.commsWs = ws;
ws.onopen = () => {
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
handleCommsEvent(msg);
} catch (e) {
}
};
ws.onclose = () => {
studio.commsWs = null;
setTimeout(initCommsWebSocket, 4000);
};
ws.onerror = () => {
};
} catch (e) {
}
}
function handleCommsEvent(msg) {
if (!msg || typeof msg !== 'object') return;
const eventType = msg.type || msg.event;
if (eventType === 'tuple_sample') {
const nodeId = msg.node_id;
const node = studio.nodes.find(n => n.id === nodeId);
if (node) {
if (!node.metrics) node.metrics = { rate: 0, inTotal: 0 };
node.metrics.inTotal = (node.metrics.inTotal || 0) + 1;
const badge = document.getElementById(`badge-${node.id}`);
if (badge) badge.innerHTML = `<span class="throughput-val">${node.metrics.inTotal}</span> msgs`;
const dot = document.getElementById(`status-dot-${node.id}`);
if (dot) dot.classList.add('streaming');
}
const droppedCount = msg.dropped_since_last !== undefined ? msg.dropped_since_last : (msg.dropped_count || 0);
studio.droppedCounts[nodeId] = (studio.droppedCounts[nodeId] || 0) + droppedCount;
if (studio.selectedNodeId === nodeId) {
const dropEl = document.getElementById('insp-tap-dropped-badge');
if (dropEl) dropEl.textContent = `Dropped: ${studio.droppedCounts[nodeId]}`;
}
const recData = msg.data !== undefined ? msg.data : (msg.record !== undefined ? msg.record : msg);
const nowStr = new Date().toISOString().split('T')[1].replace('Z', '');
const portStr = msg.port !== undefined ? `[P${msg.port}] ` : '';
const line = `[${nowStr}] ${portStr}${JSON.stringify(recData)}`;
if (!studio.terminalLines[nodeId]) studio.terminalLines[nodeId] = [];
studio.terminalLines[nodeId].push(line);
if (studio.terminalLines[nodeId].length > 60) studio.terminalLines[nodeId].shift();
if (studio.selectedNodeId === nodeId) {
const terminal = document.getElementById('insp-tap-terminal');
if (terminal) {
terminal.textContent = studio.terminalLines[nodeId].join('\n');
terminal.scrollTop = terminal.scrollHeight;
}
}
const connectedWires = studio.edges.filter(e => e.fromNode === nodeId && (msg.port === undefined || e.fromPort === msg.port));
connectedWires.forEach(edge => {
const wireEl = document.getElementById(`wire-${edge.id}`);
if (wireEl) {
wireEl.classList.add('active-stream');
setTimeout(() => {
if (node && !node.tapActive) wireEl.classList.remove('active-stream');
}, 1000);
}
});
} else if (eventType === 'connector_status') {
const status = msg.status || 'connected';
const srcType = msg.source_type || msg.kind || msg.id || '';
studio.connectorStatuses[srcType] = status;
if (studio.selectedNodeId) {
const selNode = studio.nodes.find(n => n.id === studio.selectedNodeId);
if (selNode && (selNode.type === srcType || selNode.id.includes(srcType))) {
const statVal = document.getElementById('insp-connector-status-val');
if (statVal) {
statVal.textContent = status.toUpperCase();
statVal.style.color = status.toLowerCase() === 'connected' ? '#10B981' : '#EF4444';
}
}
}
}
}
function updateInspectorWireTap(node) {
const toggleBtn = document.getElementById('insp-tap-toggle-btn');
const statusPill = document.getElementById('insp-tap-status-pill');
const droppedBadge = document.getElementById('insp-tap-dropped-badge');
const connRow = document.getElementById('insp-connector-status-row');
const terminal = document.getElementById('insp-tap-terminal');
const isTapped = !!node.tapActive;
if (toggleBtn) {
toggleBtn.textContent = isTapped ? '⏹ Disable Tap' : '⚡ Enable Tap';
toggleBtn.style.background = isTapped ? '#334155' : '#ea580c';
}
if (statusPill) {
statusPill.textContent = isTapped ? 'Streaming' : 'Standby';
statusPill.className = `badge badge-${isTapped ? 'emerald' : 'slate'}`;
}
if (droppedBadge) {
droppedBadge.textContent = `Dropped: ${studio.droppedCounts[node.id] || 0}`;
}
if (connRow) {
if (node.category === 'source' || node.category === 'sink') {
connRow.style.display = 'block';
const stat = studio.connectorStatuses[node.type] || 'Connected';
const statVal = document.getElementById('insp-connector-status-val');
if (statVal) {
statVal.textContent = stat.toUpperCase();
statVal.style.color = stat.toLowerCase() === 'connected' ? '#10B981' : '#EF4444';
}
} else {
connRow.style.display = 'none';
}
}
if (terminal) {
const lines = studio.terminalLines[node.id] || [];
terminal.textContent = lines.length > 0
? lines.join('\n')
: (isTapped ? 'Wire-tap enabled. Waiting for stream records...' : 'No active wire-tap on this node. Click \'Enable Tap\' to stream live tuples across /comms.');
terminal.scrollTop = terminal.scrollHeight;
}
}
async function toggleSelectedNodeWireTap() {
if (!studio.selectedNodeId) return;
const node = studio.nodes.find(n => n.id === studio.selectedNodeId);
if (!node) return;
const willEnable = !node.tapActive;
node.tapActive = willEnable;
const endpoint = `/rules/${encodeURIComponent(studio.ruleId)}/tap/${encodeURIComponent(node.id)}/${willEnable ? 'enable' : 'disable'}`;
try {
await apiFetch(endpoint, { method: 'POST' });
} catch (e) {
}
if (studio.commsWs && studio.commsWs.readyState === WebSocket.OPEN) {
try {
studio.commsWs.send(JSON.stringify({
action: willEnable ? 'enable' : 'disable',
rule: studio.ruleId,
node: node.id
}));
} catch (e) {}
}
const pulseEl = document.getElementById(`tap-indicator-${node.id}`);
if (pulseEl) pulseEl.style.display = willEnable ? 'inline-flex' : 'none';
const dot = document.getElementById(`status-dot-${node.id}`);
if (dot) dot.classList.toggle('streaming', willEnable);
updateInspectorWireTap(node);
showToast(`Wire-tap on '${node.id}' ${willEnable ? 'enabled (max 20 msg/s)' : 'disabled'}`, 'success');
manageStudioSimulator(willEnable);
}
function manageStudioSimulator(enable) {
if (studio.simInterval) {
clearInterval(studio.simInterval);
studio.simInterval = null;
}
const anyTapped = studio.nodes.some(n => n.tapActive);
if (!anyTapped) return;
studio.simInterval = setInterval(() => {
const activeNodes = studio.nodes.filter(n => n.tapActive);
if (activeNodes.length === 0) {
clearInterval(studio.simInterval);
studio.simInterval = null;
return;
}
activeNodes.forEach(node => {
let port = 0;
let sampleData = {};
if (node.type === 'switch') {
const temp = Number((68 + Math.random() * 22).toFixed(1));
const press = Number((90 + Math.random() * 32).toFixed(1));
port = temp > 75 ? 0 : 1;
sampleData = {
device: "Edge-Gateway-04",
temperature: temp,
pressure_psi: press,
routed_port: port,
ts: Date.now()
};
} else if (node.category === 'source') {
sampleData = {
device: "Detroit-Press-5",
plant: "Detroit-Plant",
oil_temp: Number((55 + Math.random() * 28).toFixed(1)),
pressure_psi: Number((95 + Math.random() * 30).toFixed(1)),
valve_open: Math.random() > 0.35,
ts: Date.now()
};
} else if (node.category === 'sink') {
sampleData = {
sink: node.id,
target: node.type,
status: "DELIVERED",
payload: { alert: "Threshold exceeded", val: Number((82 + Math.random() * 12).toFixed(1)) },
ts: Date.now()
};
} else {
sampleData = {
node: node.id,
type: node.type,
processed: true,
value: Number((10 + Math.random() * 90).toFixed(2)),
ts: Date.now()
};
}
handleCommsEvent({
type: 'tuple_sample',
rule_id: studio.ruleId,
node_id: node.id,
port: port,
data: sampleData
});
});
}, 850);
}
function clearTapTerminal() {
if (!studio.selectedNodeId) return;
studio.terminalLines[studio.selectedNodeId] = [];
const terminal = document.getElementById('insp-tap-terminal');
if (terminal) terminal.textContent = 'Terminal cleared.';
}
const fleetState = {
devices: [],
rollouts: [],
snapshots: [],
audit: [],
dlqAlerts: [],
activeInspectDeviceId: null,
currentProxyPath: '/rules',
initialized: false,
pollInterval: null
};
function initFleetIfNeeded() {
if (!fleetState.initialized) {
fleetState.initialized = true;
refreshFleetData(false);
fleetState.pollInterval = setInterval(() => {
const fleetView = document.getElementById('view-fleet');
if (fleetView && fleetView.style.display !== 'none') {
refreshFleetData(false);
}
}, 3000);
} else {
refreshFleetData(true);
}
}
async function refreshFleetData(showToasts = false) {
try {
const [devRes, rollRes, snapRes, auditRes, dlqRes] = await Promise.all([
apiFetch('/api/v1/fleet/devices'),
apiFetch('/api/v1/fleet/rollouts'),
apiFetch('/api/v1/fleet/snapshots'),
apiFetch('/api/v1/fleet/audit'),
apiFetch('/api/v1/fleet/dlq')
]);
if (devRes.ok && Array.isArray(devRes.body)) {
fleetState.devices = devRes.body;
}
if (rollRes.ok && Array.isArray(rollRes.body)) {
fleetState.rollouts = rollRes.body;
}
if (snapRes.ok && Array.isArray(snapRes.body)) {
fleetState.snapshots = snapRes.body;
}
if (auditRes.ok && Array.isArray(auditRes.body)) {
fleetState.audit = auditRes.body;
}
if (dlqRes.ok && Array.isArray(dlqRes.body)) {
fleetState.dlqAlerts = dlqRes.body;
}
renderFleetKpis();
renderFleetPipelineVisual();
renderFleetRollouts();
renderFleetDevices();
renderFleetDlq();
renderFleetAuditLog();
populateFilterDropdowns();
populateSnapshotSelect();
if (showToasts) {
showToast(`Fleet telemetry updated (${fleetState.devices.length} devices)`, 'success');
}
} catch (err) {
console.error('Failed to refresh fleet data:', err);
}
}
function renderFleetKpis() {
const totalDevs = fleetState.devices.length;
const onlineDevs = fleetState.devices.filter(d => (d.status || '').toLowerCase() === 'online').length;
const totalRollouts = fleetState.rollouts.length;
const canaryRollouts = fleetState.rollouts.filter(r => r.status === 'canary_evaluating' || r.current_stage === 'canary').length;
const totalSnaps = fleetState.snapshots.length;
let totalDlq = 0;
let totalRollbacks = 0;
fleetState.devices.forEach(d => {
totalDlq += (d.dlq_count || 0);
if (d.last_rollback_reason) totalRollbacks++;
});
fleetState.dlqAlerts.forEach(a => {
if (a.dlq_count) totalDlq += a.dlq_count;
if (a.rollback_reason) totalRollbacks++;
});
document.getElementById('fleet-kpi-total').textContent = totalDevs;
document.getElementById('fleet-kpi-online-badge').textContent = `${onlineDevs} Online`;
document.getElementById('fleet-kpi-online-sub').textContent = `${onlineDevs} connected Yamux agents`;
document.getElementById('fleet-kpi-rollouts').textContent = totalRollouts;
document.getElementById('fleet-kpi-canary-sub').textContent = `${canaryRollouts} Canary evaluating`;
document.getElementById('fleet-kpi-snapshots').textContent = totalSnaps;
const dlqEl = document.getElementById('fleet-kpi-dlq');
const dlqBadge = document.getElementById('fleet-kpi-dlq-badge');
dlqEl.textContent = totalDlq;
if (totalDlq > 0 || totalRollbacks > 0) {
dlqBadge.className = 'badge badge-rose';
dlqBadge.textContent = `${totalRollbacks} Rollbacks / Alerts`;
} else {
dlqBadge.className = 'badge badge-slate';
dlqBadge.textContent = 'Clean';
}
document.getElementById('fleet-kpi-rollback-sub').textContent = `${totalRollbacks} watchdog rollbacks`;
}
function renderFleetPipelineVisual() {
const stages = ['dev', 'staging', 'canary', 'prod'];
const activeRollout = fleetState.rollouts.find(r => r.status === 'in_progress' || r.status === 'canary_evaluating') || fleetState.rollouts[0];
stages.forEach(s => {
const node = document.getElementById(`pipe-node-${s}`);
if (!node) return;
node.className = 'pipeline-node';
node.style.borderColor = '';
node.style.background = '';
node.style.color = '';
});
if (!activeRollout) return;
const currentStage = activeRollout.current_stage ? activeRollout.current_stage.toLowerCase() : 'dev';
const isFailed = activeRollout.status === 'rolled_back' || activeRollout.status === 'failed';
let stageIdx = 0;
if (currentStage === 'dev') stageIdx = 0;
else if (currentStage === 'staging') stageIdx = 1;
else if (currentStage === 'canary') stageIdx = 2;
else if (currentStage === 'production' || currentStage === 'prod') stageIdx = 3;
for (let i = 0; i <= stageIdx; i++) {
const node = document.getElementById(`pipe-node-${stages[i]}`);
if (!node) continue;
if (i === stageIdx) {
if (isFailed) {
node.className = 'pipeline-node failed';
} else {
node.className = 'pipeline-node active';
}
} else {
node.className = 'pipeline-node success';
}
}
}
function renderFleetRollouts() {
const tbody = document.getElementById('fleet-rollouts-tbody');
if (!tbody) return;
if (!fleetState.rollouts || fleetState.rollouts.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; color: var(--text-muted); padding: 24px;">No rollouts recorded. Click "+ New Rollout" to launch a staged Canary rollout.</td></tr>`;
return;
}
tbody.innerHTML = fleetState.rollouts.map(r => {
let statusBadge = '';
if (r.status === 'canary_evaluating') {
statusBadge = '<span class="badge badge-amber"><span class="badge-dot"></span>Canary Evaluating</span>';
} else if (r.status === 'in_progress') {
statusBadge = '<span class="badge badge-indigo"><span class="badge-dot"></span>In Progress</span>';
} else if (r.status === 'promoted') {
statusBadge = '<span class="badge badge-emerald"><span class="badge-dot"></span>Promoted 100%</span>';
} else if (r.status === 'rolled_back') {
statusBadge = '<span class="badge badge-rose"><span class="badge-dot"></span>Rolled Back (Halted)</span>';
} else {
statusBadge = `<span class="badge badge-slate">${escapeHtml(r.status)}</span>`;
}
const canPromote = (r.status === 'canary_evaluating' || r.status === 'in_progress') && (r.current_stage !== 'production');
const canRollback = (r.status === 'canary_evaluating' || r.status === 'in_progress');
return `
<tr>
<td><strong>${escapeHtml(r.rollout_id)}</strong></td>
<td><code>${escapeHtml((r.snapshot_id || '').substring(0, 10))}...</code></td>
<td><span class="badge badge-slate">${escapeHtml(r.target_group || 'all')}</span></td>
<td><strong style="text-transform: capitalize;">${escapeHtml(r.current_stage)}</strong></td>
<td>${statusBadge}</td>
<td class="tabular">${r.successful_devices || 0} / ${r.target_device_count || 0}</td>
<td style="color: ${r.last_error ? 'var(--rose-text)' : 'var(--text-muted)'}; font-size: 12px;">
${r.last_error ? escapeHtml(r.last_error) : 'Healthy — Watchdog clear'}
</td>
<td style="text-align: right; white-space: nowrap;">
${canPromote ? `<button class="btn btn-sm btn-primary" onclick="promoteRollout('${escapeHtml(r.rollout_id)}')" title="Promote to next stage or 100% Production">Promote →</button>` : ''}
${canRollback ? `<button class="btn btn-sm btn-danger" onclick="rollbackRollout('${escapeHtml(r.rollout_id)}')" title="Halt rollout immediately">Halt / Rollback</button>` : ''}
${!canPromote && !canRollback ? `<span style="font-size: 11px; color: var(--text-faint);">Complete</span>` : ''}
</td>
</tr>
`;
}).join('');
}
function renderFleetDevices() {
const tbody = document.getElementById('fleet-devices-tbody');
if (!tbody) return;
const searchTerm = (document.getElementById('fleet-filter-search')?.value || '').toLowerCase();
const plantFilter = document.getElementById('fleet-filter-plant')?.value || 'all';
const regionFilter = document.getElementById('fleet-filter-region')?.value || 'all';
const statusFilter = document.getElementById('fleet-filter-status')?.value || 'all';
const filtered = fleetState.devices.filter(d => {
if (searchTerm) {
const matchId = (d.device_id || '').toLowerCase().includes(searchTerm);
const matchName = (d.name || '').toLowerCase().includes(searchTerm);
const matchVer = (d.version || '').toLowerCase().includes(searchTerm);
if (!matchId && !matchName && !matchVer) return false;
}
const plant = d.plant || d.group?.plant || '';
const region = d.region || d.group?.region || '';
if (plantFilter !== 'all' && plant !== plantFilter) return false;
if (regionFilter !== 'all' && region !== regionFilter) return false;
if (statusFilter !== 'all' && (d.status || '').toLowerCase() !== statusFilter.toLowerCase()) return false;
return true;
});
document.getElementById('fleet-catalog-count-badge').textContent = `${filtered.length} of ${fleetState.devices.length} devices`;
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align: center; color: var(--text-muted); padding: 24px;">No devices matched the selected filter criteria.</td></tr>`;
return;
}
tbody.innerHTML = filtered.map(d => {
const isOnline = (d.status || '').toLowerCase() === 'online';
const isDegraded = (d.status || '').toLowerCase() === 'degraded';
const statusBadge = isOnline
? '<span class="badge badge-emerald"><span class="badge-dot"></span>Online</span>'
: isDegraded
? '<span class="badge badge-amber"><span class="badge-dot"></span>Degraded</span>'
: '<span class="badge badge-slate"><span class="badge-dot"></span>Offline</span>';
const cpuVal = (d.cpu_usage ?? d.cpu_percent ?? 0).toFixed(1);
const memVal = d.memory_bytes ? (d.memory_bytes / 1024 / 1024).toFixed(0) : 0;
const cpuMem = `${cpuVal}% / ${memVal} MB`;
const activeSnap = d.active_snapshot_id
? `<span title="${escapeHtml(d.active_snapshot_hash || '')}"><code>${escapeHtml(d.active_snapshot_id.substring(0, 10))}</code></span>`
: '<span style="color: var(--text-faint);">-</span>';
const dlqText = (d.dlq_count > 0)
? `<strong style="color: var(--rose-text);">${d.dlq_count}</strong>`
: '0';
const heartbeatTime = d.last_heartbeat ? new Date(d.last_heartbeat * 1000).toLocaleTimeString() : 'Never';
const plant = d.plant || d.group?.plant || 'N/A';
const region = d.region || d.group?.region || 'N/A';
const devType = d.device_type || d.group?.device_type || 'edge-gateway';
const rulesTotal = d.rules_count ?? d.rule_count ?? 0;
const rulesRunning = d.running_rules ?? rulesTotal;
return `
<tr>
<td>${statusBadge}</td>
<td>
<strong>${escapeHtml(d.name || d.device_id)}</strong>
<div style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono);">${escapeHtml(d.device_id)}</div>
</td>
<td>
<div>${escapeHtml(plant)}</div>
<div style="font-size: 11px; color: var(--text-muted);">${escapeHtml(region)}</div>
</td>
<td>
<div>${escapeHtml(devType)}</div>
<div style="font-size: 11px; color: var(--text-muted);">v${escapeHtml(d.version || '0.1.0')}</div>
</td>
<td class="tabular">${cpuMem}</td>
<td>${activeSnap}</td>
<td class="tabular">${rulesRunning} / ${rulesTotal} rules (${dlqText} DLQ)</td>
<td class="tabular" style="font-size: 12px; color: var(--text-muted);">${heartbeatTime}</td>
<td style="text-align: right;">
<button class="btn btn-sm btn-primary" onclick="openRemoteInspectModal('${escapeHtml(d.device_id)}')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
Remote Inspect
</button>
</td>
</tr>
`;
}).join('');
}
function renderFleetDlq() {
const tbody = document.getElementById('fleet-dlq-tbody');
if (!tbody) return;
const alerts = [];
fleetState.devices.forEach(d => {
if (d.dlq_count > 0 || d.last_rollback_reason) {
alerts.push({
device_id: d.device_id,
dlq_count: d.dlq_count || 0,
reason: d.last_rollback_reason || 'DLQ messages accumulated',
time: d.last_heartbeat ? new Date(d.last_heartbeat * 1000).toLocaleTimeString() : 'Recent'
});
}
});
fleetState.dlqAlerts.forEach(a => {
alerts.push({
device_id: a.device_id,
dlq_count: a.dlq_count || 0,
reason: a.rollback_reason || a.error || 'Alert trigger',
time: a.timestamp ? new Date(a.timestamp * 1000).toLocaleTimeString() : 'Recent'
});
});
document.getElementById('fleet-dlq-count-badge').textContent = `${alerts.length} Alerts`;
if (alerts.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align: center; color: var(--text-muted); padding: 18px;">No edge anomalies or DLQ overflows reported.</td></tr>`;
return;
}
tbody.innerHTML = alerts.map(a => `
<tr>
<td><code>${escapeHtml(a.device_id)}</code></td>
<td class="tabular"><strong style="color: var(--rose-text);">${a.dlq_count}</strong></td>
<td style="font-size: 12px; color: var(--rose-text);">${escapeHtml(a.reason)}</td>
<td class="tabular" style="font-size: 12px; color: var(--text-muted);">${escapeHtml(a.time)}</td>
</tr>
`).join('');
}
function renderFleetAuditLog() {
const tbody = document.getElementById('fleet-audit-tbody');
if (!tbody) return;
if (!fleetState.audit || fleetState.audit.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; color: var(--text-muted); padding: 18px;">Audit log is currently empty.</td></tr>`;
return;
}
tbody.innerHTML = fleetState.audit.map(entry => {
const timeStr = entry.timestamp ? new Date(entry.timestamp * 1000).toLocaleTimeString() : '';
const isFailed = entry.status === 'failed' || (entry.details && typeof entry.details === 'object' && (entry.details.error || entry.details.status === 'error'));
const statusBadge = isFailed
? '<span class="badge badge-rose" style="padding: 1px 6px; font-size: 11px;">Failed</span>'
: '<span class="badge badge-emerald" style="padding: 1px 6px; font-size: 11px;">Success</span>';
const target = entry.target_id || entry.target || '-';
let detailsStr = '-';
if (entry.details !== undefined && entry.details !== null) {
if (typeof entry.details === 'object') {
if (entry.action === 'proxy_request' && entry.details.method && entry.details.path) {
detailsStr = `${entry.details.method} ${entry.details.path}`;
} else if (Object.keys(entry.details).length === 0) {
detailsStr = '-';
} else {
try {
detailsStr = JSON.stringify(entry.details);
} catch (e) {
detailsStr = String(entry.details);
}
}
} else {
detailsStr = String(entry.details);
}
}
return `
<tr>
<td class="tabular" style="font-size: 11.5px; color: var(--text-muted);">${timeStr}</td>
<td><strong>${escapeHtml(entry.action)}</strong></td>
<td><code>${escapeHtml(target)}</code></td>
<td>${statusBadge}</td>
<td style="font-size: 12px; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(detailsStr)}">
${escapeHtml(detailsStr)}
</td>
</tr>
`;
}).join('');
}
function populateFilterDropdowns() {
const plantSelect = document.getElementById('fleet-filter-plant');
const regionSelect = document.getElementById('fleet-filter-region');
if (!plantSelect || !regionSelect) return;
const currentPlant = plantSelect.value;
const currentRegion = regionSelect.value;
const plants = new Set();
const regions = new Set();
fleetState.devices.forEach(d => {
const plant = d.plant || d.group?.plant;
const region = d.region || d.group?.region;
if (plant) plants.add(plant);
if (region) regions.add(region);
});
plantSelect.innerHTML = '<option value="all">All Plants</option>' +
Array.from(plants).sort().map(p => `<option value="${escapeHtml(p)}" ${p === currentPlant ? 'selected' : ''}>${escapeHtml(p)}</option>`).join('');
regionSelect.innerHTML = '<option value="all">All Regions</option>' +
Array.from(regions).sort().map(r => `<option value="${escapeHtml(r)}" ${r === currentRegion ? 'selected' : ''}>${escapeHtml(r)}</option>`).join('');
}
function populateSnapshotSelect() {
const select = document.getElementById('new-rollout-snapshot-id');
if (!select) return;
const current = select.value;
if (!fleetState.snapshots || fleetState.snapshots.length === 0) {
select.innerHTML = '<option value="">No snapshots found (create one via /api/v1/fleet/snapshots)</option>';
return;
}
select.innerHTML = '<option value="">Select an immutable snapshot...</option>' +
fleetState.snapshots.map(s => `
<option value="${escapeHtml(s.snapshot_id)}" ${s.snapshot_id === current ? 'selected' : ''}>
${escapeHtml(s.snapshot_id)} (${escapeHtml((s.sha256_hash || '').substring(0, 8))}...)
</option>
`).join('');
}
function openRemoteInspectModal(deviceId) {
const device = fleetState.devices.find(d => d.device_id === deviceId);
fleetState.activeInspectDeviceId = deviceId;
fleetState.currentProxyPath = '/rules';
document.getElementById('inspect-modal-device-id').textContent = `[${deviceId}]`;
document.getElementById('inspect-device-plant').textContent = device?.plant || device?.group?.plant || '-';
document.getElementById('inspect-device-region').textContent = device?.region || device?.group?.region || '-';
document.getElementById('inspect-device-type').textContent = device?.device_type || device?.group?.device_type || '-';
const isOnline = (device?.status || '').toLowerCase() === 'online';
const badge = document.getElementById('inspect-device-status-badge');
badge.className = isOnline ? 'badge badge-emerald' : 'badge badge-rose';
badge.textContent = isOnline ? 'Online (Yamux Stream 1..N Active)' : 'Offline';
document.getElementById('fleet-inspect-modal').style.display = 'flex';
fetchProxyEndpoint('/rules');
}
function closeRemoteInspectModal() {
document.getElementById('fleet-inspect-modal').style.display = 'none';
fleetState.activeInspectDeviceId = null;
}
async function fetchProxyEndpoint(subpath) {
if (!fleetState.activeInspectDeviceId) return;
fleetState.currentProxyPath = subpath;
const devId = fleetState.activeInspectDeviceId;
const displayPath = (!subpath || subpath === '/') ? '/' : (subpath.startsWith('/') ? subpath : '/' + subpath);
document.getElementById('inspect-target-url-label').textContent = `Tunneled: GET /api/v1/fleet/devices/${devId}/proxy${displayPath}`;
const box = document.getElementById('inspect-response-box');
box.textContent = `Tunneling GET ${displayPath} to edge gateway '${devId}' over Yamux stream...`;
try {
const queryPath = (!subpath || subpath === '/') ? '' : (subpath.startsWith('/') ? subpath : '/' + subpath);
const res = await apiFetch(`/api/v1/fleet/devices/${encodeURIComponent(devId)}/proxy${queryPath}`);
if (res.ok) {
box.textContent = (typeof res.body === 'object') ? JSON.stringify(res.body, null, 2) : res.body;
} else {
box.textContent = `Proxy tunnel error (HTTP ${res.status}):\n${typeof res.body === 'object' ? JSON.stringify(res.body, null, 2) : res.body}`;
}
} catch (e) {
box.textContent = `Proxy connection failed: ${e.message}`;
}
}
function refreshCurrentProxyInspection() {
fetchProxyEndpoint(fleetState.currentProxyPath);
}
function openNewRolloutModal() {
document.getElementById('new-rollout-id').value = `rollout-${Date.now().toString().slice(-6)}`;
populateSnapshotSelect();
document.getElementById('fleet-rollout-modal').style.display = 'flex';
}
function closeNewRolloutModal() {
document.getElementById('fleet-rollout-modal').style.display = 'none';
}
async function submitCreateRollout() {
const rolloutId = document.getElementById('new-rollout-id').value.trim();
const snapshotId = document.getElementById('new-rollout-snapshot-id').value.trim();
const targetGroup = document.getElementById('new-rollout-group').value.trim() || 'all';
const stage = document.getElementById('new-rollout-stage').value;
if (!rolloutId || !snapshotId) {
showToast('Please provide both Rollout ID and Snapshot ID', 'error');
return;
}
try {
const payload = {
rollout_id: rolloutId,
snapshot_id: snapshotId,
target_group: targetGroup,
current_stage: stage,
status: (stage === 'canary') ? 'canary_evaluating' : 'in_progress',
canary_percentage: 5,
promoted_at: null,
created_at: Math.floor(Date.now() / 1000)
};
const res = await apiFetch('/api/v1/fleet/rollouts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
showToast(`Staged rollout '${rolloutId}' launched!`, 'success');
closeNewRolloutModal();
refreshFleetData(false);
} else {
showToast(`Failed to create rollout: ${res.status}`, 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function promoteRollout(rolloutId) {
try {
const res = await apiFetch(`/api/v1/fleet/rollouts/${encodeURIComponent(rolloutId)}/promote`, {
method: 'POST'
});
if (res.ok) {
showToast(`Rollout '${rolloutId}' promoted successfully!`, 'success');
refreshFleetData(false);
} else {
showToast(`Promotion failed (HTTP ${res.status})`, 'error');
}
} catch (e) {
showToast(`Promotion error: ${e.message}`, 'error');
}
}
async function rollbackRollout(rolloutId) {
try {
const res = await apiFetch(`/api/v1/fleet/rollouts/${encodeURIComponent(rolloutId)}/rollback`, {
method: 'POST'
});
if (res.ok) {
showToast(`Rollout '${rolloutId}' halted and marked rolled_back`, 'info');
refreshFleetData(false);
} else {
showToast(`Rollback failed (HTTP ${res.status})`, 'error');
}
} catch (e) {
showToast(`Rollback error: ${e.message}`, 'error');
}
}
const ruleDesignerState = {
isEditing: false,
currentStep: 1,
queryMode: 'form', ruleId: '',
source: '',
selectFields: '*',
where: '',
windowType: 'none',
windowVal: 10,
windowUnit: 'ss',
groupby: '',
having: '',
orderby: '',
limit: '',
rawSql: '',
sinks: [],
options: {
qos: 0,
checkpointInterval: 300000,
concurrency: 1,
bufferLength: 1024,
isEventTime: false,
timestampField: 'ts',
lateTolerance: 1000,
sendMetaToSink: false,
sendError: false,
triggered: false
}
};
function resetRuleDesignerState() {
ruleDesignerState.isEditing = false;
ruleDesignerState.currentStep = 1;
ruleDesignerState.queryMode = 'form';
ruleDesignerState.ruleId = '';
ruleDesignerState.source = state.streams.length > 0 ? (state.streams[0].name || state.streams[0]) : '';
ruleDesignerState.selectFields = '*';
ruleDesignerState.where = '';
ruleDesignerState.windowType = 'none';
ruleDesignerState.windowVal = 10;
ruleDesignerState.windowUnit = 'ss';
ruleDesignerState.groupby = '';
ruleDesignerState.having = '';
ruleDesignerState.orderby = '';
ruleDesignerState.limit = '';
ruleDesignerState.rawSql = '';
ruleDesignerState.sinks = [
{
type: 'mqtt',
server: 'tcp://127.0.0.1:1883',
topic: 'rekuiper/alerts',
qos: 0,
clientId: '',
confKey: '',
sendSingle: true,
dataTemplate: '',
omitIfEmpty: false,
runAsync: false
}
];
ruleDesignerState.options = {
qos: 0,
checkpointInterval: 300000,
concurrency: 1,
bufferLength: 1024,
isEventTime: false,
timestampField: 'ts',
lateTolerance: 1000,
sendMetaToSink: false,
sendError: false,
triggered: false
};
}
function openNewRuleDesigner() {
resetRuleDesignerState();
document.getElementById('rule-designer-modal-title').textContent = 'Guided Rule Pipeline Designer';
document.getElementById('rd-rule-id').readOnly = false;
populateRuleDesignerSources();
syncStateToRuleDesignerUI();
updateWindowExplanation();
updateStreamFieldPills();
goToRuleDesignerStep(1);
document.getElementById('rule-designer-modal').style.display = 'flex';
updateRuleDesignerPreview();
}
async function openExistingRuleDesigner(ruleId) {
resetRuleDesignerState();
ruleDesignerState.isEditing = true;
ruleDesignerState.ruleId = ruleId;
document.getElementById('rule-designer-modal-title').textContent = `Edit Pipeline: ${ruleId}`;
document.getElementById('rd-rule-id').readOnly = true;
populateRuleDesignerSources();
try {
const res = await apiFetch(`/rules/${encodeURIComponent(ruleId)}`);
if (res.ok && res.body) {
const rule = res.body;
ruleDesignerState.rawSql = rule.sql || '';
ruleDesignerState.ruleId = rule.id || ruleId;
if (Array.isArray(rule.actions) && rule.actions.length > 0) {
ruleDesignerState.sinks = rule.actions.map(act => {
const type = Object.keys(act)[0] || 'mqtt';
const props = act[type] || {};
return {
type,
server: props.server || 'tcp://127.0.0.1:1883',
topic: props.topic || '',
url: props.url || '',
method: props.method || 'POST',
headers: props.headers ? JSON.stringify(props.headers) : '',
brokers: props.brokers || '',
path: props.path || '',
qos: props.qos || 0,
clientId: props.clientId || '',
confKey: props.confKey || '',
sendSingle: Boolean(props.sendSingle),
dataTemplate: props.dataTemplate || '',
omitIfEmpty: Boolean(props.omitIfEmpty),
runAsync: Boolean(props.runAsync)
};
});
}
if (rule.options) {
const o = rule.options;
ruleDesignerState.options.qos = o.qos ?? 0;
ruleDesignerState.options.checkpointInterval = o.checkpointInterval ?? 300000;
ruleDesignerState.options.concurrency = o.concurrency ?? 1;
ruleDesignerState.options.bufferLength = o.bufferLength ?? 1024;
ruleDesignerState.options.isEventTime = Boolean(o.isEventTime);
ruleDesignerState.options.timestampField = o.timestampFormat || o.timestampField || 'ts';
ruleDesignerState.options.lateTolerance = o.lateTolerance ?? 1000;
ruleDesignerState.options.sendMetaToSink = Boolean(o.sendMetaToSink);
ruleDesignerState.options.sendError = Boolean(o.sendError);
ruleDesignerState.options.triggered = Boolean(o.triggered);
}
ruleDesignerState.queryMode = 'raw';
}
} catch (err) {
console.error('Failed to load rule for designer:', err);
}
syncStateToRuleDesignerUI();
goToRuleDesignerStep(1);
document.getElementById('rule-designer-modal').style.display = 'flex';
updateRuleDesignerPreview();
}
function closeRuleDesignerModal() {
document.getElementById('rule-designer-modal').style.display = 'none';
}
function populateRuleDesignerSources() {
const select = document.getElementById('rd-source-select');
select.innerHTML = '<option value="">Select source stream/table...</option>';
state.streams.forEach(s => {
const name = typeof s === 'string' ? s : (s.name || s.id);
const opt = document.createElement('option');
opt.value = name;
opt.textContent = `Stream: ${name}`;
select.appendChild(opt);
});
state.tables.forEach(t => {
const name = typeof t === 'string' ? t : (t.name || t.id);
const opt = document.createElement('option');
opt.value = name;
opt.textContent = `Table: ${name}`;
select.appendChild(opt);
});
}
function syncStateToRuleDesignerUI() {
document.getElementById('rd-rule-id').value = ruleDesignerState.ruleId;
document.getElementById('rd-source-select').value = ruleDesignerState.source;
document.getElementById('rd-select-fields').value = ruleDesignerState.selectFields;
document.getElementById('rd-where-clause').value = ruleDesignerState.where;
document.getElementById('rd-window-type').value = ruleDesignerState.windowType;
document.getElementById('rd-window-val').value = ruleDesignerState.windowVal;
document.getElementById('rd-window-unit').value = ruleDesignerState.windowUnit;
document.getElementById('rd-groupby-clause').value = ruleDesignerState.groupby;
document.getElementById('rd-having-clause').value = ruleDesignerState.having;
document.getElementById('rd-orderby-clause').value = ruleDesignerState.orderby;
document.getElementById('rd-limit-clause').value = ruleDesignerState.limit;
document.getElementById('rd-raw-sql').value = ruleDesignerState.rawSql;
setRuleQueryMode(ruleDesignerState.queryMode);
renderDesignerSinks();
document.getElementById('rd-opt-qos').value = String(ruleDesignerState.options.qos);
document.getElementById('rd-opt-checkpoint').value = ruleDesignerState.options.checkpointInterval;
document.getElementById('rd-opt-concurrency').value = ruleDesignerState.options.concurrency;
document.getElementById('rd-opt-buffer').value = ruleDesignerState.options.bufferLength;
document.getElementById('rd-opt-is-event-time').checked = ruleDesignerState.options.isEventTime;
document.getElementById('rd-opt-ts-field').value = ruleDesignerState.options.timestampField;
document.getElementById('rd-opt-late-tolerance').value = ruleDesignerState.options.lateTolerance;
document.getElementById('rd-opt-send-meta').checked = ruleDesignerState.options.sendMetaToSink;
document.getElementById('rd-opt-send-error').checked = ruleDesignerState.options.sendError;
document.getElementById('rd-opt-triggered').checked = ruleDesignerState.options.triggered;
toggleEventTimeFields();
}
function goToRuleDesignerStep(step) {
ruleDesignerState.currentStep = step;
[1, 2, 3].forEach(s => {
const tab = document.getElementById(`rule-step-tab-${s}`);
const view = document.getElementById(`rule-step-view-${s}`);
if (tab) {
tab.classList.toggle('active', s === step);
tab.classList.toggle('completed', s < step);
}
if (view) {
view.style.display = (s === step) ? 'flex' : 'none';
}
});
const prevBtn = document.getElementById('rd-prev-btn');
const nextBtn = document.getElementById('rd-next-btn');
const deployBtn = document.getElementById('rd-deploy-btn');
prevBtn.style.display = step > 1 ? 'inline-block' : 'none';
if (step === 3) {
nextBtn.style.display = 'none';
deployBtn.style.display = 'inline-block';
} else {
nextBtn.style.display = 'inline-block';
deployBtn.style.display = 'none';
}
updateRuleDesignerPreview();
}
function stepRuleDesigner(delta) {
const next = ruleDesignerState.currentStep + delta;
if (next >= 1 && next <= 3) {
goToRuleDesignerStep(next);
}
}
function setRuleQueryMode(mode) {
ruleDesignerState.queryMode = mode;
const btnForm = document.getElementById('rd-mode-btn-form');
const btnRaw = document.getElementById('rd-mode-btn-raw');
const panelForm = document.getElementById('rd-visual-panel');
const panelRaw = document.getElementById('rd-raw-panel');
if (mode === 'form') {
btnForm.style.background = 'var(--accent)';
btnForm.style.color = '#fff';
btnRaw.style.background = 'var(--bg-surface)';
btnRaw.style.color = 'var(--text-secondary)';
panelForm.style.display = 'flex';
panelRaw.style.display = 'none';
generateRuleSqlFromVisualForm();
} else {
btnRaw.style.background = 'var(--accent)';
btnRaw.style.color = '#fff';
btnForm.style.background = 'var(--bg-surface)';
btnForm.style.color = 'var(--text-secondary)';
panelForm.style.display = 'none';
panelRaw.style.display = 'flex';
if (!ruleDesignerState.rawSql.trim()) {
generateRuleSqlFromVisualForm();
document.getElementById('rd-raw-sql').value = ruleDesignerState.rawSql;
}
}
updateRuleDesignerPreview();
}
function loadRuleRecipe(recipeType) {
const recipes = {
overheat: {
id: 'rule_motor_overheat',
source: 'demo',
select: 'device_id, temperature, humidity',
where: 'temperature > 80.0',
windowType: 'none',
sinks: [{
type: 'mqtt',
server: 'tcp://127.0.0.1:1883',
topic: 'rekuiper/alerts/overheat',
qos: 1,
sendSingle: true
}]
},
moving_avg: {
id: 'rule_10s_avg_temperature',
source: 'demo',
select: 'device_id, avg(temperature) as avg_temp, max(temperature) as max_temp, count(*) as samples',
where: '',
windowType: 'tumbling_time',
windowVal: 10,
windowUnit: 'ss',
groupby: 'device_id',
sinks: [{
type: 'rest',
url: 'https://httpbin.org/post',
method: 'POST',
sendSingle: false
}]
},
vibration: {
id: 'rule_vibration_spike',
source: 'demo',
select: 'device_id, vibration, lag(vibration) OVER (PARTITION BY device_id) as prev_vibration',
where: 'vibration > 15.0',
windowType: 'none',
sinks: [{
type: 'mqtt',
server: 'tcp://127.0.0.1:1883',
topic: 'rekuiper/alerts/vibration',
qos: 0,
sendSingle: true
}]
},
dedup: {
id: 'rule_dedup_status',
source: 'demo',
select: 'device_id, status',
where: 'had_changed(status)',
windowType: 'none',
sinks: [{
type: 'rest',
url: 'https://httpbin.org/post',
method: 'POST',
sendSingle: true
}]
}
};
const recipe = recipes[recipeType];
if (!recipe) return;
document.getElementById('rd-rule-id').value = recipe.id;
ruleDesignerState.ruleId = recipe.id;
const sourceSelect = document.getElementById('rd-source-select');
if (sourceSelect) {
let hasSource = Array.from(sourceSelect.options).some(o => o.value === recipe.source);
if (!hasSource) {
const opt = document.createElement('option');
opt.value = recipe.source;
opt.textContent = `Stream: ${recipe.source}`;
sourceSelect.appendChild(opt);
}
sourceSelect.value = recipe.source;
ruleDesignerState.source = recipe.source;
}
document.getElementById('rd-select-fields').value = recipe.select;
ruleDesignerState.selectFields = recipe.select;
document.getElementById('rd-where-clause').value = recipe.where || '';
ruleDesignerState.where = recipe.where || '';
document.getElementById('rd-window-type').value = recipe.windowType;
ruleDesignerState.windowType = recipe.windowType;
if (recipe.windowVal) {
document.getElementById('rd-window-val').value = recipe.windowVal;
ruleDesignerState.windowVal = recipe.windowVal;
}
if (recipe.windowUnit) {
document.getElementById('rd-window-unit').value = recipe.windowUnit;
ruleDesignerState.windowUnit = recipe.windowUnit;
}
document.getElementById('rd-groupby-clause').value = recipe.groupby || '';
ruleDesignerState.groupby = recipe.groupby || '';
if (recipe.sinks) {
ruleDesignerState.sinks = recipe.sinks;
renderDesignerSinks();
}
updateWindowExplanation();
updateStreamFieldPills();
generateRuleSqlFromVisualForm();
updateRuleDesignerPreview();
showToast(`Loaded recipe: ${recipe.id} ⚡`, 'success');
}
function updateWindowExplanation() {
const type = document.getElementById('rd-window-type')?.value || 'none';
const helpBox = document.getElementById('rd-window-help-text');
const valGroup = document.getElementById('rd-window-val-group');
if (!helpBox) return;
if (valGroup) {
valGroup.style.display = (type === 'none') ? 'none' : 'grid';
}
const explanations = {
none: 'Real-time (No Window): Events are evaluated individually as soon as they arrive with sub-millisecond latency.',
tumbling_time: 'Tumbling Time Window: Non-overlapping fixed time buckets (e.g. every 10 seconds). Computes aggregation once bucket closes.',
tumbling_count: 'Tumbling Count Window: Non-overlapping event count buckets (e.g. every 100 messages) regardless of clock time.',
hopping_time: 'Hopping Window: Overlapping sliding time window. Evaluates rolling calculations with defined hop step.',
session_time: 'Session Window: Dynamically closes after an inactivity gap. Ideal for bursts and intermittent edge communication.'
};
helpBox.textContent = explanations[type] || 'Select a windowing strategy to aggregate events over time or counts.';
}
function updateStreamFieldPills() {
const container = document.getElementById('rd-stream-field-pills');
if (!container) return;
const sourceName = document.getElementById('rd-source-select')?.value || '';
let fields = ['temperature', 'humidity', 'device_id', 'status', 'pressure', 'rpm', 'voltage'];
const streamObj = state.streams.find(s => (s.name || s.id || s) === sourceName);
if (streamObj && Array.isArray(streamObj.fields) && streamObj.fields.length > 0) {
fields = streamObj.fields.map(f => f.name || f.Name || f);
}
container.innerHTML = fields.map(f => `
<button type="button" class="field-pill" onclick="appendFieldToRuleQuery('${escapeHtml(f)}')" title="Add '${escapeHtml(f)}' to SELECT statement">+ ${escapeHtml(f)}</button>
`).join('');
}
function appendFieldToRuleQuery(fieldName) {
const input = document.getElementById('rd-select-fields');
const current = input.value.trim();
if (!current || current === '*') {
input.value = fieldName;
} else {
const parts = current.split(',').map(s => s.trim());
if (!parts.includes(fieldName)) {
input.value = `${current}, ${fieldName}`;
}
}
onVisualSqlFormChange();
}
function insertSqlFunctionSnippet(funcName) {
const snippets = {
avg: 'avg(temperature) as avg_val',
max: 'max(temperature) as max_val',
min: 'min(temperature) as min_val',
count: 'count(*) as total_events',
had_changed: 'had_changed(status)',
lag: 'lag(temperature) OVER ()',
json_path: "json_path_query(payload, '$.data')",
format_time: "format_time(ts, 'YYYY-MM-DD HH:mm:ss')"
};
const snippet = snippets[funcName] || funcName;
if (ruleDesignerState.queryMode === 'raw') {
insertSqlSnippet(snippet);
} else {
appendFieldToRuleQuery(snippet);
}
}
function onRuleSourceChange() {
ruleDesignerState.source = document.getElementById('rd-source-select').value;
updateStreamFieldPills();
onVisualSqlFormChange();
}
function onVisualSqlFormChange() {
ruleDesignerState.source = document.getElementById('rd-source-select').value;
ruleDesignerState.selectFields = document.getElementById('rd-select-fields').value.trim() || '*';
ruleDesignerState.where = document.getElementById('rd-where-clause').value.trim();
ruleDesignerState.windowType = document.getElementById('rd-window-type').value;
ruleDesignerState.windowVal = parseInt(document.getElementById('rd-window-val').value, 10) || 10;
ruleDesignerState.windowUnit = document.getElementById('rd-window-unit').value;
ruleDesignerState.groupby = document.getElementById('rd-groupby-clause').value.trim();
ruleDesignerState.having = document.getElementById('rd-having-clause').value.trim();
ruleDesignerState.orderby = document.getElementById('rd-orderby-clause').value.trim();
ruleDesignerState.limit = document.getElementById('rd-limit-clause').value.trim();
generateRuleSqlFromVisualForm();
updateRuleDesignerPreview();
}
function generateRuleSqlFromVisualForm() {
const src = ruleDesignerState.source || 'demo';
let sql = `SELECT ${ruleDesignerState.selectFields} FROM ${src}`;
if (ruleDesignerState.where) {
sql += ` WHERE ${ruleDesignerState.where}`;
}
let windowClause = '';
if (ruleDesignerState.windowType === 'tumbling_time') {
windowClause = `TumblingWindow(${ruleDesignerState.windowUnit}, ${ruleDesignerState.windowVal})`;
} else if (ruleDesignerState.windowType === 'tumbling_count') {
windowClause = `CountWindow(${ruleDesignerState.windowVal})`;
} else if (ruleDesignerState.windowType === 'hopping_time') {
const step = Math.max(1, Math.floor(ruleDesignerState.windowVal / 2));
windowClause = `HoppingWindow(${ruleDesignerState.windowUnit}, ${ruleDesignerState.windowVal}, ${step})`;
} else if (ruleDesignerState.windowType === 'session_time') {
windowClause = `SessionWindow(${ruleDesignerState.windowUnit}, ${ruleDesignerState.windowVal})`;
}
let groups = [];
if (windowClause) groups.push(windowClause);
if (ruleDesignerState.groupby) groups.push(ruleDesignerState.groupby);
if (groups.length > 0) {
sql += ` GROUP BY ${groups.join(', ')}`;
}
if (ruleDesignerState.having) {
sql += ` HAVING ${ruleDesignerState.having}`;
}
if (ruleDesignerState.orderby) {
sql += ` ORDER BY ${ruleDesignerState.orderby}`;
}
if (ruleDesignerState.limit) {
sql += ` LIMIT ${ruleDesignerState.limit}`;
}
ruleDesignerState.rawSql = sql;
document.getElementById('rd-raw-sql').value = sql;
}
function onRawSqlChange() {
ruleDesignerState.rawSql = document.getElementById('rd-raw-sql').value;
updateRuleDesignerPreview();
}
function insertSqlSnippet(snippet) {
const textarea = document.getElementById('rd-raw-sql');
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
textarea.value = text.substring(0, start) + snippet + text.substring(end);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + snippet.length;
onRawSqlChange();
}
function addRuleDesignerSink() {
ruleDesignerState.sinks.push({
type: 'mqtt',
server: 'tcp://127.0.0.1:1883',
topic: 'rekuiper/alerts',
qos: 0,
clientId: '',
confKey: '',
sendSingle: true,
dataTemplate: '',
omitIfEmpty: false,
runAsync: false
});
renderDesignerSinks();
updateRuleDesignerPreview();
}
function removeRuleDesignerSink(idx) {
if (ruleDesignerState.sinks.length <= 1) {
showToast('At least one egress sink is required', 'warning');
return;
}
ruleDesignerState.sinks.splice(idx, 1);
renderDesignerSinks();
updateRuleDesignerPreview();
}
function onSinkFieldChange(idx, field, value) {
if (!ruleDesignerState.sinks[idx]) return;
ruleDesignerState.sinks[idx][field] = value;
updateRuleDesignerPreview();
}
function renderDesignerSinks() {
const container = document.getElementById('rd-sinks-container');
if (!container) return;
container.innerHTML = ruleDesignerState.sinks.map((sink, idx) => {
let typeSpecificFields = '';
if (sink.type === 'mqtt') {
typeSpecificFields = `
<div style="display: grid; grid-template-columns: 1.2fr 1fr 0.8fr; gap: 8px;">
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Broker URL</label>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(sink.server || 'tcp://127.0.0.1:1883')}" placeholder="tcp://127.0.0.1:1883" oninput="onSinkFieldChange(${idx}, 'server', this.value)" />
</div>
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Publish Topic</label>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(sink.topic || '')}" placeholder="rekuiper/alerts" oninput="onSinkFieldChange(${idx}, 'topic', this.value)" />
</div>
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">QoS</label>
<select class="select-input" style="width:100%; font-size:12px;" onchange="onSinkFieldChange(${idx}, 'qos', parseInt(this.value, 10))">
<option value="0" ${sink.qos === 0 ? 'selected' : ''}>0 - At Most Once</option>
<option value="1" ${sink.qos === 1 ? 'selected' : ''}>1 - At Least Once</option>
<option value="2" ${sink.qos === 2 ? 'selected' : ''}>2 - Exactly Once</option>
</select>
</div>
</div>
`;
} else if (sink.type === 'rest') {
typeSpecificFields = `
<div style="display: grid; grid-template-columns: 0.8fr 1.6fr; gap: 8px;">
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">HTTP Method</label>
<select class="select-input" style="width:100%; font-size:12px;" onchange="onSinkFieldChange(${idx}, 'method', this.value)">
<option value="POST" ${sink.method === 'POST' ? 'selected' : ''}>POST</option>
<option value="PUT" ${sink.method === 'PUT' ? 'selected' : ''}>PUT</option>
</select>
</div>
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Destination Endpoint URL</label>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(sink.url || '')}" placeholder="https://api.cloud.corp/v1/telemetry" oninput="onSinkFieldChange(${idx}, 'url', this.value)" />
</div>
</div>
`;
} else if (sink.type === 'kafka') {
typeSpecificFields = `
<div style="display: grid; grid-template-columns: 1.2fr 1fr; gap: 8px;">
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Bootstrap Brokers</label>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(sink.brokers || '')}" placeholder="10.0.0.10:9092,10.0.0.11:9092" oninput="onSinkFieldChange(${idx}, 'brokers', this.value)" />
</div>
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Topic</label>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(sink.topic || '')}" placeholder="edge_events" oninput="onSinkFieldChange(${idx}, 'topic', this.value)" />
</div>
</div>
`;
} else if (sink.type === 'file') {
typeSpecificFields = `
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">File Output Path</label>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(sink.path || '')}" placeholder="/var/log/rekuiper/output.csv" oninput="onSinkFieldChange(${idx}, 'path', this.value)" />
</div>
`;
} else if (sink.type === 'memory') {
typeSpecificFields = `
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Internal Topic Name</label>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(sink.topic || '')}" placeholder="mem_stream_01" oninput="onSinkFieldChange(${idx}, 'topic', this.value)" />
</div>
`;
} else {
typeSpecificFields = `
<div style="font-size:12px; color:var(--text-muted);">
Sink ${escapeHtml(sink.type)} will output events using default engine handler.
</div>
`;
}
return `
<div class="sink-item-card">
<div class="sink-item-header">
<div style="display:flex; align-items:center; gap:8px;">
<span class="badge badge-slate" style="font-weight:700;">#${idx + 1}</span>
<select class="select-input" style="font-size:12px; font-weight:600;" onchange="onSinkFieldChange(${idx}, 'type', this.value); renderDesignerSinks();">
<option value="mqtt" ${sink.type === 'mqtt' ? 'selected' : ''}>MQTT Broker</option>
<option value="rest" ${sink.type === 'rest' ? 'selected' : ''}>REST / HTTP API</option>
<option value="kafka" ${sink.type === 'kafka' ? 'selected' : ''}>Apache Kafka</option>
<option value="file" ${sink.type === 'file' ? 'selected' : ''}>File / Parquet</option>
<option value="memory" ${sink.type === 'memory' ? 'selected' : ''}>In-Memory Bus</option>
<option value="log" ${sink.type === 'log' ? 'selected' : ''}>Standard Console Log</option>
<option value="edgex" ${sink.type === 'edgex' ? 'selected' : ''}>EdgeX Foundry</option>
<option value="influxdb" ${sink.type === 'influxdb' ? 'selected' : ''}>InfluxDB</option>
<option value="nop" ${sink.type === 'nop' ? 'selected' : ''}>No-Op (Benchmark Drop)</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<button type="button" class="btn btn-sm" style="font-size:11px; padding:2px 8px;" onclick="openSinkAdvancedProperties(${idx})" title="Fine-tune protocol parameters & security">⚙ Advanced</button>
<button type="button" class="btn btn-sm" style="color:var(--rose-text); padding:2px 8px;" onclick="removeRuleDesignerSink(${idx})" title="Remove Sink">✕ Remove</button>
</div>
</div>
${typeSpecificFields}
<!-- Formatting & Delivery Controls -->
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:10px; border-top:1px dashed var(--border); padding-top:8px;">
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Serialization Format</label>
<select class="select-input" style="width:100%; font-size:11px;" onchange="onSinkFieldChange(${idx}, 'format', this.value); renderDesignerSinks();">
<option value="json" ${(!sink.format || sink.format === 'json') ? 'selected' : ''}>JSON (Standard Object)</option>
<option value="protobuf" ${sink.format === 'protobuf' ? 'selected' : ''}>Protobuf 3 (Binary Contract)</option>
<option value="custom" ${sink.format === 'custom' ? 'selected' : ''}>Custom Binary Codec</option>
<option value="delimited" ${sink.format === 'delimited' ? 'selected' : ''}>Delimited / CSV</option>
</select>
</div>
<div>
${(sink.format === 'protobuf' || sink.format === 'custom') ? `
<div style="display:grid; grid-template-columns: 1fr ${sink.format === 'protobuf' ? '1fr' : ''}; gap:6px;">
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Schema ID</label>
<select class="select-input" style="width:100%; font-size:11px; font-family:var(--font-mono);" onchange="onSinkFieldChange(${idx}, 'schemaId', this.value); onSinkSchemaSelectChange(${idx});">
<option value="">-- Schema --</option>
${getSchemaOptionsHtml(sink.format, sink.schemaId)}
</select>
</div>
${sink.format === 'protobuf' ? `
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Message Type</label>
<select class="select-input" style="width:100%; font-size:11px; font-family:var(--font-mono);" onchange="onSinkFieldChange(${idx}, 'messageType', this.value)">
<option value="">-- Message --</option>
${getSchemaMessageOptionsHtml(sink.schemaId, sink.messageType)}
</select>
</div>
` : ''}
</div>
` : (sink.format === 'delimited' ? `
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Delimiter Char</label>
<input type="text" class="text-input" style="width:100%; font-size:11px; font-family:var(--font-mono);" value="${escapeHtml(sink.delimiter || ',')}" placeholder="," maxlength="4" oninput="onSinkFieldChange(${idx}, 'delimiter', this.value)" />
</div>
` : `
<div>
<label style="display:block; font-size:11px; font-weight:600; margin-bottom:2px;">Golang DataTemplate (Optional)</label>
<input type="text" class="text-input" style="width:100%; font-size:11px; font-family:var(--font-mono);" value="${escapeHtml(sink.dataTemplate || '')}" placeholder='{"device":"{{.device_id}}"}' oninput="onSinkFieldChange(${idx}, 'dataTemplate', this.value)" />
</div>
`)}
</div>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:6px;">
<label style="display:flex; align-items:center; gap:6px; font-size:11px; cursor:pointer;">
<input type="checkbox" ${sink.sendSingle ? 'checked' : ''} onchange="onSinkFieldChange(${idx}, 'sendSingle', this.checked)" />
<span>Send Single (individual events)</span>
</label>
<label style="display:flex; align-items:center; gap:6px; font-size:11px; cursor:pointer;">
<input type="checkbox" ${sink.runAsync ? 'checked' : ''} onchange="onSinkFieldChange(${idx}, 'runAsync', this.checked)" />
<span>Run Asynchronous</span>
</label>
</div>
</div>
`;
}).join('');
}
function toggleEventTimeFields() {
const enabled = document.getElementById('rd-opt-is-event-time').checked;
document.getElementById('rd-event-time-fields').style.display = enabled ? 'grid' : 'none';
ruleDesignerState.options.isEventTime = enabled;
}
function buildRulePayload(maskSecrets = false) {
const ruleId = document.getElementById('rd-rule-id').value.trim() || 'rule_custom';
const sql = ruleDesignerState.queryMode === 'raw'
? (document.getElementById('rd-raw-sql').value.trim() || ruleDesignerState.rawSql)
: ruleDesignerState.rawSql;
const actions = ruleDesignerState.sinks.map(s => {
const props = Object.assign({}, s.advancedProps || {});
if (s.type === 'mqtt') {
props.server = s.server || 'tcp://127.0.0.1:1883';
props.topic = s.topic || 'rekuiper/alerts';
props.qos = s.qos || 0;
if (s.clientId) props.clientId = s.clientId;
if (s.confKey) props.confKey = s.confKey;
} else if (s.type === 'rest') {
props.url = s.url || 'https://api.cloud.corp/v1/telemetry';
props.method = s.method || 'POST';
if (s.headers) {
try { props.headers = JSON.parse(s.headers); } catch (_) {}
}
} else if (s.type === 'kafka') {
props.brokers = s.brokers || '127.0.0.1:9092';
props.topic = s.topic || 'events';
} else if (s.type === 'file') {
props.path = s.path || '/var/log/rekuiper/output.csv';
} else if (s.type === 'memory') {
props.topic = s.topic || 'mem_stream';
}
if (s.format && s.format !== 'json') {
props.format = s.format;
if (s.schemaId) props.schemaId = s.schemaId;
if (s.messageType) props.messageType = s.messageType;
if (s.delimiter) props.delimiter = s.delimiter;
}
if (s.sendSingle) props.sendSingle = true;
if (s.dataTemplate) props.dataTemplate = s.dataTemplate;
if (s.omitIfEmpty) props.omitIfEmpty = true;
if (s.runAsync) props.runAsync = true;
if (maskSecrets) {
Object.keys(props).forEach(k => {
if (/password|secret|token|key|cert/i.test(k) && typeof props[k] === 'string') {
props[k] = '********';
}
});
}
return { [s.type]: props };
});
const options = {
qos: parseInt(document.getElementById('rd-opt-qos').value, 10) || 0,
checkpointInterval: parseInt(document.getElementById('rd-opt-checkpoint').value, 10) || 300000,
concurrency: parseInt(document.getElementById('rd-opt-concurrency').value, 10) || 1,
bufferLength: parseInt(document.getElementById('rd-opt-buffer').value, 10) || 1024,
sendMetaToSink: document.getElementById('rd-opt-send-meta').checked,
sendError: document.getElementById('rd-opt-send-error').checked,
triggered: document.getElementById('rd-opt-triggered').checked
};
if (document.getElementById('rd-opt-is-event-time').checked) {
options.isEventTime = true;
options.timestampFormat = document.getElementById('rd-opt-ts-field').value.trim() || 'ts';
options.lateTolerance = parseInt(document.getElementById('rd-opt-late-tolerance').value, 10) || 1000;
}
return {
id: ruleId,
sql,
actions,
options
};
}
function updateRuleDesignerPreview() {
const mask = document.getElementById('rd-mask-secrets').checked;
const payload = buildRulePayload(mask);
const pre = document.getElementById('rd-preview-json');
if (pre) {
pre.textContent = JSON.stringify(payload, null, 2);
}
}
function copyRuleDesignerJson() {
const payload = buildRulePayload(false);
navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => {
showToast('Pipeline JSON copied to clipboard', 'info');
}).catch(err => {
showToast(`Copy failed: ${err.message}`, 'error');
});
}
async function validateRuleDesignerWithEngine() {
const btn = document.getElementById('rd-validate-btn');
const msgEl = document.getElementById('rd-validation-msg');
btn.disabled = true;
btn.textContent = 'Validating with Engine...';
msgEl.style.display = 'none';
try {
const payload = buildRulePayload(false);
const res = await apiFetch('/rules/validate', {
method: 'POST',
body: payload
});
msgEl.style.display = 'block';
if (res.ok) {
msgEl.innerHTML = `<span class="badge badge-emerald" style="display:inline-block; margin-top:4px; padding:4px 8px;">✔ Engine Validated: SQL syntax and sinks verified</span>`;
showToast('Rule pipeline validated successfully!', 'success');
} else {
const errMsg = (res.body && (res.body.message || res.body.error)) ? (res.body.message || JSON.stringify(res.body)) : `Validation failed (HTTP ${res.status})`;
msgEl.innerHTML = `<span class="badge badge-rose" style="display:inline-block; margin-top:4px; padding:4px 8px; text-align:left; white-space:pre-wrap;">✗ ${escapeHtml(errMsg)}</span>`;
showToast(`Engine rejected: ${errMsg}`, 'error');
}
} catch (err) {
msgEl.style.display = 'block';
msgEl.innerHTML = `<span class="badge badge-rose" style="display:inline-block; margin-top:4px; padding:4px 8px;">✗ Validation error: ${escapeHtml(err.message)}</span>`;
} finally {
btn.disabled = false;
btn.textContent = '✔ Validate with Engine Preflight';
}
}
async function submitRuleDesignerDeployment() {
const payload = buildRulePayload(false);
if (!payload.id) {
showToast('Rule ID is required', 'warning');
goToRuleDesignerStep(1);
return;
}
if (!payload.sql) {
showToast('SQL statement cannot be empty', 'warning');
goToRuleDesignerStep(1);
return;
}
const method = ruleDesignerState.isEditing ? 'PUT' : 'POST';
const endpoint = ruleDesignerState.isEditing ? `/rules/${encodeURIComponent(payload.id)}` : '/rules';
try {
const res = await apiFetch(endpoint, {
method,
body: payload
});
if (res.ok) {
showToast(`Pipeline '${payload.id}' deployed successfully!`, 'success');
closeRuleDesignerModal();
refreshAll();
} else {
const errMsg = (res.body && res.body.message) ? res.body.message : `Deployment failed (${res.status})`;
showToast(`Deployment error: ${errMsg}`, 'error');
}
} catch (err) {
showToast(`Deployment error: ${err.message}`, 'error');
}
}
const streamDesignerState = {
isEditing: false,
kind: 'stream', name: '',
type: 'mqtt',
datasource: 'factory/+/sensors',
format: 'json',
confKey: '',
isSchemaless: false,
fields: [
{ name: 'device_id', type: 'string', optional: true },
{ name: 'temperature', type: 'float', optional: true },
{ name: 'ts', type: 'bigint', optional: true }
]
};
function openNewStreamDesigner(kind = 'stream') {
streamDesignerState.isEditing = false;
streamDesignerState.kind = kind;
streamDesignerState.name = '';
streamDesignerState.type = 'mqtt';
streamDesignerState.datasource = kind === 'stream' ? 'factory/+/sensors' : 'modbus_holding_regs';
streamDesignerState.format = 'json';
streamDesignerState.confKey = '';
streamDesignerState.isSchemaless = false;
streamDesignerState.fields = [
{ name: 'device_id', type: 'string', optional: true },
{ name: 'temperature', type: 'float', optional: true },
{ name: 'ts', type: 'bigint', optional: true }
];
setStreamDesignerKind(kind);
document.getElementById('sd-name').readOnly = false;
document.getElementById('sd-name').value = '';
document.getElementById('sd-datasource').value = streamDesignerState.datasource;
document.getElementById('sd-format').value = streamDesignerState.format;
document.getElementById('sd-schemaless-toggle').checked = false;
onStreamFormatChange();
loadAvailableConfKeys(streamDesignerState.type);
renderStreamFieldsTable();
updateStreamDesignerDdl();
document.getElementById('stream-designer-modal').style.display = 'flex';
}
async function openExistingStreamDesigner(kind, name) {
streamDesignerState.isEditing = true;
streamDesignerState.kind = kind;
streamDesignerState.name = name;
setStreamDesignerKind(kind);
document.getElementById('sd-name').value = name;
document.getElementById('sd-name').readOnly = true;
try {
const endpoint = kind === 'stream' ? `/streams/${encodeURIComponent(name)}` : `/tables/${encodeURIComponent(name)}`;
const res = await apiFetch(endpoint);
if (res.ok && res.body) {
const body = res.body;
if (body.options) {
document.getElementById('sd-connector-type').value = body.options.TYPE || body.options.type || 'mqtt';
document.getElementById('sd-datasource').value = body.options.DATASOURCE || body.options.datasource || '';
const fmt = body.options.FORMAT || body.options.format || 'json';
document.getElementById('sd-format').value = fmt;
streamDesignerState.confKey = body.options.CONF_KEY || body.options.conf_key || '';
onStreamFormatChange();
if (body.options.SCHEMAID || body.options.schemaId) {
const sid = body.options.SCHEMAID || body.options.schemaId;
const sSel = document.getElementById('sd-schema-select');
if (sSel) {
sSel.value = sid;
onStreamSchemaSelectChange();
if (body.options.MESSAGETYPE || body.options.messageType) {
const mSel = document.getElementById('sd-message-type-select');
if (mSel) mSel.value = body.options.MESSAGETYPE || body.options.messageType;
}
}
}
if (body.options.DELIMITER || body.options.delimiter) {
const dInp = document.getElementById('sd-delimiter-input');
if (dInp) dInp.value = body.options.DELIMITER || body.options.delimiter;
}
}
if (Array.isArray(body.fields) && body.fields.length > 0) {
streamDesignerState.isSchemaless = false;
document.getElementById('sd-schemaless-toggle').checked = false;
streamDesignerState.fields = body.fields.map(f => ({
name: f.name || f.Name,
type: f.fieldType ? (f.fieldType.Type || f.fieldType.type || 'string') : (f.type || 'string'),
optional: true
}));
} else {
streamDesignerState.isSchemaless = true;
document.getElementById('sd-schemaless-toggle').checked = true;
streamDesignerState.fields = [];
}
}
} catch (err) {
console.error('Failed to load item:', err);
}
loadAvailableConfKeys(document.getElementById('sd-connector-type').value);
toggleSchemalessMode();
renderStreamFieldsTable();
updateStreamDesignerDdl();
document.getElementById('stream-designer-modal').style.display = 'flex';
}
function closeStreamDesignerModal() {
document.getElementById('stream-designer-modal').style.display = 'none';
}
function toggleInferJsonBox() {
const box = document.getElementById('sd-infer-box');
if (!box) return;
const isVisible = box.style.display === 'block';
box.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
const input = document.getElementById('sd-sample-json-input');
if (input && !input.value.trim()) {
input.value = JSON.stringify({
device_id: "plc_motor_01",
temperature: 78.4,
vibration: 2.15,
rpm: 3600,
status: "RUNNING",
is_overheat: false,
timestamp: Date.now()
}, null, 2);
}
}
}
function inferStreamFieldsFromJson() {
const input = document.getElementById('sd-sample-json-input');
const raw = (input?.value || '').trim();
if (!raw) {
showToast('Please paste a sample JSON payload first', 'warning');
return;
}
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch (err) {
showToast(`Invalid JSON syntax: ${err.message}`, 'error');
return;
}
if (Array.isArray(parsed) && parsed.length > 0) {
parsed = parsed[0];
}
if (typeof parsed !== 'object' || parsed === null) {
showToast('Sample JSON must be an object or array of objects', 'warning');
return;
}
const inferred = [];
for (const [key, val] of Object.entries(parsed)) {
let type = 'string';
if (typeof val === 'number') {
type = Number.isInteger(val) ? 'bigint' : 'float';
} else if (typeof val === 'boolean') {
type = 'boolean';
} else if (Array.isArray(val)) {
type = 'array';
} else if (typeof val === 'object' && val !== null) {
type = 'struct';
} else if (typeof val === 'string') {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
type = 'datetime';
}
}
inferred.push({ name: key, type, optional: true });
}
streamDesignerState.fields = inferred;
streamDesignerState.isSchemaless = false;
document.getElementById('sd-schemaless-toggle').checked = false;
document.getElementById('sd-fields-section').style.display = 'flex';
renderStreamFieldsTable();
updateStreamDesignerDdl();
showToast(`Inferred ${inferred.length} typed fields from JSON payload 🪄`, 'success');
}
function loadStreamTemplate(templateType) {
const templates = {
temperature_sensor: {
name: 'stream_env_sensors',
connector: 'mqtt',
datasource: 'factory/sensors/env',
format: 'json',
fields: [
{ name: 'device_id', type: 'string', optional: true },
{ name: 'temperature', type: 'float', optional: true },
{ name: 'humidity', type: 'float', optional: true },
{ name: 'pressure', type: 'float', optional: true },
{ name: 'timestamp', type: 'bigint', optional: true }
]
},
modbus_telemetry: {
name: 'stream_modbus_plc',
connector: 'modbus',
datasource: 'plc/holding_registers',
format: 'json',
fields: [
{ name: 'slave_id', type: 'bigint', optional: true },
{ name: 'motor_rpm', type: 'bigint', optional: true },
{ name: 'line_voltage', type: 'float', optional: true },
{ name: 'status_coil', type: 'boolean', optional: true }
]
},
opcua_tags: {
name: 'stream_opcua_tags',
connector: 'opcua',
datasource: 'opc.tcp://127.0.0.1:4840',
format: 'json',
fields: [
{ name: 'node_id', type: 'string', optional: true },
{ name: 'val', type: 'float', optional: true },
{ name: 'quality', type: 'string', optional: true },
{ name: 'source_timestamp', type: 'datetime', optional: true }
]
},
canbus_vehicle: {
name: 'stream_can_vehicle',
connector: 'mqtt',
datasource: 'vehicle/can/telemetry',
format: 'json',
fields: [
{ name: 'can_id', type: 'bigint', optional: true },
{ name: 'speed_kmh', type: 'float', optional: true },
{ name: 'engine_load_pct', type: 'float', optional: true },
{ name: 'battery_temp_c', type: 'float', optional: true }
]
}
};
const tpl = templates[templateType];
if (!tpl) return;
document.getElementById('sd-name').value = tpl.name;
document.getElementById('sd-connector-type').value = tpl.connector;
document.getElementById('sd-datasource').value = tpl.datasource;
document.getElementById('sd-format').value = tpl.format;
streamDesignerState.fields = tpl.fields;
streamDesignerState.isSchemaless = false;
document.getElementById('sd-schemaless-toggle').checked = false;
document.getElementById('sd-fields-section').style.display = 'flex';
renderStreamFieldsTable();
updateStreamDesignerDdl();
showToast(`Loaded ${tpl.name} template ⚡`, 'info');
}
function setStreamDesignerKind(kind) {
streamDesignerState.kind = kind;
const btnStream = document.getElementById('sd-kind-stream');
const btnTable = document.getElementById('sd-kind-table');
const label = document.getElementById('sd-name-label');
const submitBtn = document.getElementById('sd-submit-btn');
if (kind === 'stream') {
btnStream.style.background = 'var(--accent)';
btnStream.style.color = '#fff';
btnTable.style.background = 'var(--bg-surface)';
btnTable.style.color = 'var(--text-secondary)';
label.innerHTML = 'Stream Name <span style="color:var(--rose-dot)">*</span>';
submitBtn.textContent = streamDesignerState.isEditing ? 'Update Stream Definition' : 'Create Stream';
} else {
btnTable.style.background = 'var(--accent)';
btnTable.style.color = '#fff';
btnStream.style.background = 'var(--bg-surface)';
btnStream.style.color = 'var(--text-secondary)';
label.innerHTML = 'Table Name <span style="color:var(--rose-dot)">*</span>';
submitBtn.textContent = streamDesignerState.isEditing ? 'Update Table Definition' : 'Create Table';
}
updateStreamDesignerDdl();
}
function toggleSchemalessMode() {
const schemaless = document.getElementById('sd-schemaless-toggle').checked;
streamDesignerState.isSchemaless = schemaless;
document.getElementById('sd-fields-section').style.display = schemaless ? 'none' : 'flex';
updateStreamDesignerDdl();
}
function onStreamConnectorChange() {
const connector = document.getElementById('sd-connector-type').value;
streamDesignerState.type = connector;
loadAvailableConfKeys(connector);
updateStreamDesignerDdl();
}
async function loadAvailableConfKeys(connectorType) {
const select = document.getElementById('sd-confkey-select');
select.innerHTML = '<option value="">Default (Global / inline defaults)</option>';
try {
const res = await apiFetch(`/metadata/sources/${encodeURIComponent(connectorType)}`);
if (res.ok && res.body && Array.isArray(res.body.confKeys)) {
res.body.confKeys.forEach(k => {
const opt = document.createElement('option');
opt.value = k;
opt.textContent = `ConfKey: ${k}`;
if (k === streamDesignerState.confKey) opt.selected = true;
select.appendChild(opt);
});
}
} catch (_) {}
}
function addStreamFieldRow() {
streamDesignerState.fields.push({
name: `field_${streamDesignerState.fields.length + 1}`,
type: 'float',
optional: true
});
renderStreamFieldsTable();
updateStreamDesignerDdl();
}
function removeStreamFieldRow(idx) {
streamDesignerState.fields.splice(idx, 1);
renderStreamFieldsTable();
updateStreamDesignerDdl();
}
function onStreamFieldChange(idx, key, value) {
if (!streamDesignerState.fields[idx]) return;
streamDesignerState.fields[idx][key] = value;
updateStreamDesignerDdl();
}
function renderStreamFieldsTable() {
const tbody = document.getElementById('sd-fields-tbody');
if (!tbody) return;
if (streamDesignerState.fields.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="padding:14px;">No fields defined. Click "+ Add Field" above to specify schema attributes.</td></tr>`;
return;
}
tbody.innerHTML = streamDesignerState.fields.map((f, idx) => `
<tr>
<td>
<input type="text" class="text-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" value="${escapeHtml(f.name)}" placeholder="field_name" oninput="onStreamFieldChange(${idx}, 'name', this.value)" />
</td>
<td>
<select class="select-input" style="width:100%; font-size:12px; font-family:var(--font-mono);" onchange="onStreamFieldChange(${idx}, 'type', this.value)">
<option value="bigint" ${f.type === 'bigint' ? 'selected' : ''}>bigint (64-bit integer)</option>
<option value="float" ${f.type === 'float' ? 'selected' : ''}>float (64-bit double)</option>
<option value="string" ${f.type === 'string' ? 'selected' : ''}>string (text)</option>
<option value="boolean" ${f.type === 'boolean' ? 'selected' : ''}>boolean (true / false)</option>
<option value="datetime" ${f.type === 'datetime' ? 'selected' : ''}>datetime (ISO timestamp)</option>
<option value="bytea" ${f.type === 'bytea' ? 'selected' : ''}>bytea (raw byte buffer)</option>
<option value="array" ${f.type === 'array' ? 'selected' : ''}>array (JSON array)</option>
<option value="struct" ${f.type === 'struct' ? 'selected' : ''}>struct (Nested object)</option>
</select>
</td>
<td style="text-align: center;">
<input type="checkbox" ${f.optional ? 'checked' : ''} onchange="onStreamFieldChange(${idx}, 'optional', this.checked)" />
</td>
<td style="text-align: center;">
<button type="button" class="btn btn-sm" style="color:var(--rose-text); padding:2px 6px;" onclick="removeStreamFieldRow(${idx})" title="Delete Field">✕</button>
</td>
</tr>
`).join('');
}
function generateStreamDdlSql() {
const kindKeyword = streamDesignerState.kind === 'table' ? 'TABLE' : 'STREAM';
const name = document.getElementById('sd-name').value.trim() || 'demo_stream';
const type = document.getElementById('sd-connector-type').value;
const datasource = document.getElementById('sd-datasource').value.trim() || 'data/topic';
const format = document.getElementById('sd-format').value;
const confKey = document.getElementById('sd-confkey-select').value;
let fieldsDef = '';
if (!streamDesignerState.isSchemaless && streamDesignerState.fields.length > 0) {
const fieldLines = streamDesignerState.fields.map(f => ` ${f.name || 'col'} ${f.type}`);
fieldsDef = ` (\n${fieldLines.join(',\n')}\n)`;
} else {
fieldsDef = ' ()';
}
let withProps = [
` DATASOURCE = "${datasource}"`,
` FORMAT = "${format}"`,
` TYPE = "${type}"`
];
if (confKey) {
withProps.push(` CONF_KEY = "${confKey}"`);
}
if (format === 'protobuf') {
const schemaId = document.getElementById('sd-schema-select')?.value;
const msgType = document.getElementById('sd-message-type-select')?.value;
if (schemaId) withProps.push(` SCHEMAID = "${schemaId}"`);
if (msgType) withProps.push(` MESSAGETYPE = "${msgType}"`);
} else if (format === 'custom') {
const schemaId = document.getElementById('sd-schema-select')?.value;
if (schemaId) withProps.push(` SCHEMAID = "${schemaId}"`);
} else if (format === 'delimited') {
const delim = document.getElementById('sd-delimiter-input')?.value || ',';
withProps.push(` DELIMITER = "${delim}"`);
}
return `CREATE ${kindKeyword} ${name}${fieldsDef} WITH (\n${withProps.join(',\n')}\n);`;
}
function onStreamFormatChange() {
const format = document.getElementById('sd-format').value;
const schemaRow = document.getElementById('sd-schema-options-row');
const delimRow = document.getElementById('sd-delimited-options-row');
const msgWrapper = document.getElementById('sd-msg-type-wrapper');
const autofillBtn = document.getElementById('sd-autofill-btn');
if (format === 'protobuf' || format === 'custom') {
if (schemaRow) schemaRow.style.display = 'block';
if (delimRow) delimRow.style.display = 'none';
if (msgWrapper) msgWrapper.style.display = format === 'protobuf' ? 'block' : 'none';
if (autofillBtn) autofillBtn.style.display = format === 'protobuf' ? 'inline-block' : 'none';
populateStreamSchemaSelect(format);
} else if (format === 'delimited') {
if (schemaRow) schemaRow.style.display = 'none';
if (delimRow) delimRow.style.display = 'block';
} else {
if (schemaRow) schemaRow.style.display = 'none';
if (delimRow) delimRow.style.display = 'none';
}
updateStreamDesignerDdl();
}
function onStreamSchemaSelectChange() {
const schemaId = document.getElementById('sd-schema-select')?.value;
const msgSelect = document.getElementById('sd-message-type-select');
if (!msgSelect) return;
msgSelect.innerHTML = '<option value="">-- Select Message Type --</option>';
if (schemaId) {
const found = schemasState.items.find(s => s.name === schemaId && s.kind === 'protobuf');
if (found && Array.isArray(found.messages)) {
found.messages.forEach(m => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
msgSelect.appendChild(opt);
});
if (found.messages.length > 0) {
msgSelect.value = found.messages[0];
}
}
}
updateStreamDesignerDdl();
}
function autofillStreamFieldsFromSchema() {
const schemaId = document.getElementById('sd-schema-select')?.value;
const msgType = document.getElementById('sd-message-type-select')?.value;
if (!schemaId) {
showToast('Please select a registered schema first', 'warning');
return;
}
const found = schemasState.items.find(s => s.name === schemaId);
if (!found || !found.parsed || !found.parsed.messages) {
showToast('No parsed message fields found in selected schema', 'warning');
return;
}
const targetMsg = found.parsed.messages.find(m => m.name === msgType) || found.parsed.messages[0];
if (!targetMsg || !targetMsg.fields || targetMsg.fields.length === 0) {
showToast('Selected message contains no fields', 'warning');
return;
}
const mapped = targetMsg.fields.map(f => {
let t = 'string';
const raw = (f.type || '').toLowerCase();
if (raw === 'double' || raw === 'float') t = 'float';
else if (raw.includes('int') || raw.includes('fixed')) t = 'bigint';
else if (raw === 'bool' || raw === 'boolean') t = 'boolean';
else if (raw === 'bytes') t = 'bytea';
else if (f.rule === 'repeated') t = 'array';
return { name: f.name, type: t, optional: true };
});
streamDesignerState.fields = mapped;
streamDesignerState.isSchemaless = false;
document.getElementById('sd-schemaless-toggle').checked = false;
document.getElementById('sd-fields-section').style.display = 'flex';
renderStreamFieldsTable();
updateStreamDesignerDdl();
showToast(`Autofilled ${mapped.length} typed fields from message '${targetMsg.name}' 🪄`, 'success');
}
function updateStreamDesignerDdl() {
const sql = generateStreamDdlSql();
const pre = document.getElementById('sd-ddl-preview');
if (pre) pre.textContent = sql;
}
function copyStreamDesignerDdl() {
const sql = generateStreamDdlSql();
navigator.clipboard.writeText(sql).then(() => {
showToast('DDL SQL copied to clipboard', 'info');
}).catch(err => {
showToast(`Copy failed: ${err.message}`, 'error');
});
}
async function submitStreamDesigner() {
const name = document.getElementById('sd-name').value.trim();
if (!name) {
showToast('Name is required', 'warning');
return;
}
const sql = generateStreamDdlSql();
const endpoint = streamDesignerState.kind === 'table' ? '/tables' : '/streams';
try {
const res = await apiFetch(endpoint, {
method: 'POST',
body: { sql }
});
if (res.ok) {
showToast(`${streamDesignerState.kind === 'table' ? 'Table' : 'Stream'} '${name}' created!`, 'success');
closeStreamDesignerModal();
refreshAll();
} else {
const errMsg = (res.body && (res.body.message || res.body.error)) ? (res.body.message || JSON.stringify(res.body)) : `Creation failed (${res.status})`;
showToast(`Error: ${errMsg}`, 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function deleteCatalogItem(kind, name) {
if (!confirm(`Are you sure you want to delete ${kind} '${name}'? This cannot be undone.`)) {
return;
}
const endpoint = kind === 'table' ? `/tables/${encodeURIComponent(name)}` : `/streams/${encodeURIComponent(name)}`;
try {
const res = await apiFetch(endpoint, { method: 'DELETE' });
if (res.ok) {
showToast(`${kind === 'table' ? 'Table' : 'Stream'} '${name}' deleted`, 'info');
refreshAll();
} else {
showToast(`Failed to delete ${kind} (${res.status})`, 'error');
}
} catch (err) {
showToast(`Delete error: ${err.message}`, 'error');
}
}
function openInlineConfKeyModal(connectorType = 'mqtt') {
document.getElementById('inline-ck-type').value = connectorType;
document.getElementById('inline-ck-name').value = '';
document.getElementById('inline-ck-json').value = JSON.stringify({
server: connectorType === 'mqtt' ? 'tcp://127.0.0.1:1883' : 'http://127.0.0.1:8080',
username: 'admin',
password: 'password'
}, null, 2);
document.getElementById('inline-confkey-modal').style.display = 'flex';
}
function closeInlineConfKeyModal() {
document.getElementById('inline-confkey-modal').style.display = 'none';
}
async function submitInlineConfKey() {
const type = document.getElementById('inline-ck-type').value.trim();
const keyName = document.getElementById('inline-ck-name').value.trim();
const rawJson = document.getElementById('inline-ck-json').value.trim();
if (!keyName) {
showToast('ConfKey name is required', 'warning');
return;
}
let parsedConfig = {};
try {
parsedConfig = JSON.parse(rawJson);
} catch (err) {
showToast('Invalid JSON in ConfKey properties', 'error');
return;
}
try {
const endpoint = `/metadata/sources/${encodeURIComponent(type)}/confKeys/${encodeURIComponent(keyName)}`;
const res = await apiFetch(endpoint, {
method: 'PUT',
body: parsedConfig
});
if (res.ok) {
showToast(`ConfKey '${keyName}' saved for ${type}!`, 'success');
closeInlineConfKeyModal();
streamDesignerState.confKey = keyName;
await loadAvailableConfKeys(type);
document.getElementById('sd-confkey-select').value = keyName;
updateStreamDesignerDdl();
} else {
showToast(`Failed to save ConfKey (${res.status})`, 'error');
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
const BUILTIN_CONNECTOR_CATALOG = {
sources: {
mqtt: [
{ name: 'server', type: 'string', control: 'text', default: 'tcp://127.0.0.1:1883', optional: false, label: { en: 'Broker Server' }, hint: { en: 'Address of MQTT broker (e.g. tcp://127.0.0.1:1883, ssl://...)' }, connection_related: true },
{ name: 'protocolVersion', type: 'string', control: 'select', default: '3.1.1', values: ['3.1.1', '3.1', '5.0'], optional: true, label: { en: 'Protocol Version' }, connection_related: true },
{ name: 'clientid', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Client ID' }, connection_related: true },
{ name: 'username', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Username' }, connection_related: true },
{ name: 'password', type: 'string', control: 'password', default: '', optional: true, label: { en: 'Password' }, connection_related: true },
{ name: 'qos', type: 'int', control: 'select', default: 1, values: [0, 1, 2], optional: true, label: { en: 'QoS' } },
{ name: 'insecureSkipVerify', type: 'bool', control: 'switch', default: false, optional: true, label: { en: 'Insecure Skip TLS Verify' }, connection_related: true },
{ name: 'certificationPath', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Client Certificate Path' }, connection_related: true },
{ name: 'privateKeyPath', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Client Private Key Path' }, connection_related: true },
{ name: 'rootCaPath', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Root CA Certificate Path' }, connection_related: true }
],
http: [
{ name: 'url', type: 'string', control: 'text', default: 'http://127.0.0.1:8080/data', optional: false, label: { en: 'Target URL' }, hint: { en: 'HTTP endpoint for ingestion pull' }, connection_related: true },
{ name: 'method', type: 'string', control: 'select', default: 'GET', values: ['GET', 'POST', 'PUT'], optional: true, label: { en: 'HTTP Method' } },
{ name: 'interval', type: 'int', control: 'number', default: 1000, optional: true, label: { en: 'Polling Interval (ms)' } },
{ name: 'timeout', type: 'int', control: 'number', default: 5000, optional: true, label: { en: 'Timeout (ms)' } },
{ name: 'headers', type: 'json', control: 'textarea', default: '{}', optional: true, label: { en: 'HTTP Request Headers (JSON)' } },
{ name: 'body', type: 'string', control: 'textarea', default: '', optional: true, label: { en: 'Request Body' } },
{ name: 'insecureSkipVerify', type: 'bool', control: 'switch', default: false, optional: true, label: { en: 'Insecure Skip TLS Verify' }, connection_related: true }
],
kafka: [
{ name: 'brokers', type: 'string', control: 'text', default: '127.0.0.1:9092', optional: false, label: { en: 'Kafka Brokers' }, hint: { en: 'Comma-separated broker list' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: 'events', optional: false, label: { en: 'Topic' } },
{ name: 'groupid', type: 'string', control: 'text', default: 'rekuiper_group', optional: true, label: { en: 'Consumer Group ID' }, connection_related: true },
{ name: 'saslAuthType', type: 'string', control: 'select', default: 'none', values: ['none', 'plain', 'scram-sha-256', 'scram-sha-512'], optional: true, label: { en: 'SASL Auth Type' }, connection_related: true },
{ name: 'saslUserName', type: 'string', control: 'text', default: '', optional: true, label: { en: 'SASL Username' }, connection_related: true },
{ name: 'saslPassword', type: 'string', control: 'password', default: '', optional: true, label: { en: 'SASL Password' }, connection_related: true }
],
file: [
{ name: 'path', type: 'string', control: 'text', default: '/data/telemetry.csv', optional: false, label: { en: 'File Path' } },
{ name: 'fileType', type: 'string', control: 'select', default: 'json', values: ['json', 'csv', 'lines', 'parquet'], optional: true, label: { en: 'File Format' } },
{ name: 'interval', type: 'int', control: 'number', default: 1000, optional: true, label: { en: 'Read Interval (ms)' } },
{ name: 'hasHeader', type: 'bool', control: 'switch', default: true, optional: true, label: { en: 'CSV Header Row' } }
],
edgex: [
{ name: 'protocol', type: 'string', control: 'select', default: 'redis', values: ['redis', 'mqtt', 'zmq'], optional: true, label: { en: 'Protocol' }, connection_related: true },
{ name: 'server', type: 'string', control: 'text', default: '127.0.0.1', optional: false, label: { en: 'Server Address' }, connection_related: true },
{ name: 'port', type: 'int', control: 'number', default: 6379, optional: false, label: { en: 'Port' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: 'events', optional: true, label: { en: 'Topic / Channel' } },
{ name: 'type', type: 'string', control: 'select', default: 'redis', values: ['redis', 'mqtt', 'zmq'], optional: true, label: { en: 'MessageBus Type' }, connection_related: true }
],
modbus: [
{ name: 'url', type: 'string', control: 'text', default: 'tcp://127.0.0.1:502', optional: false, label: { en: 'Modbus Endpoint URL' }, hint: { en: 'tcp://ip:port or rtu://device' }, connection_related: true },
{ name: 'slaveId', type: 'int', control: 'number', default: 1, optional: true, label: { en: 'Slave Unit ID' }, connection_related: true },
{ name: 'interval', type: 'int', control: 'number', default: 1000, optional: true, label: { en: 'Poll Interval (ms)' } }
],
opcua: [
{ name: 'url', type: 'string', control: 'text', default: 'opc.tcp://127.0.0.1:4840', optional: false, label: { en: 'OPC-UA Endpoint URL' }, connection_related: true },
{ name: 'securityPolicy', type: 'string', control: 'select', default: 'None', values: ['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256'], optional: true, label: { en: 'Security Policy' }, connection_related: true },
{ name: 'securityMode', type: 'string', control: 'select', default: 'None', values: ['None', 'Sign', 'SignAndEncrypt'], optional: true, label: { en: 'Security Mode' }, connection_related: true },
{ name: 'username', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Username' }, connection_related: true },
{ name: 'password', type: 'string', control: 'password', default: '', optional: true, label: { en: 'Password' }, connection_related: true },
{ name: 'interval', type: 'int', control: 'number', default: 1000, optional: true, label: { en: 'Subscription Interval (ms)' } }
],
neuron: [
{ name: 'url', type: 'string', control: 'text', default: 'tcp://127.0.0.1:7081', optional: false, label: { en: 'Neuron NNG URL' }, connection_related: true },
{ name: 'interval', type: 'int', control: 'number', default: 1000, optional: true, label: { en: 'Poll Interval (ms)' } }
],
redis: [
{ name: 'addr', type: 'string', control: 'text', default: 'redis://127.0.0.1:6379', optional: false, label: { en: 'Redis URL' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: 'rekuiper_stream', optional: true, label: { en: 'Channel or Stream Key' } },
{ name: 'datatype', type: 'string', control: 'select', default: 'channel', values: ['channel', 'stream', 'list'], optional: true, label: { en: 'Data Type' } }
],
sql: [
{ name: 'url', type: 'string', control: 'text', default: 'mysql://root:password@127.0.0.1:3306/test', optional: false, label: { en: 'Database URL' }, hint: { en: 'mysql://..., postgres://..., sqlite://...' }, connection_related: true },
{ name: 'table', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Table Name' } },
{ name: 'interval', type: 'int', control: 'number', default: 1000, optional: true, label: { en: 'Scan Interval (ms)' } }
],
zmq: [
{ name: 'server', type: 'string', control: 'text', default: 'tcp://127.0.0.1:5563', optional: false, label: { en: 'ZeroMQ Server' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Topic Filter' } },
{ name: 'mode', type: 'string', control: 'select', default: 'connect', values: ['connect', 'bind'], optional: true, label: { en: 'Socket Mode' }, connection_related: true }
],
simulator: [
{ name: 'interval', type: 'int', control: 'number', default: 100, optional: true, label: { en: 'Event Spacing (ms)' } }
]
},
sinks: {
mqtt: [
{ name: 'server', type: 'string', control: 'text', default: 'tcp://127.0.0.1:1883', optional: false, label: { en: 'Broker Server' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: 'rekuiper/alerts', optional: false, label: { en: 'Publish Topic' } },
{ name: 'qos', type: 'int', control: 'select', default: 0, values: [0, 1, 2], optional: true, label: { en: 'QoS' } },
{ name: 'retained', type: 'bool', control: 'switch', default: false, optional: true, label: { en: 'Retained Message' } },
{ name: 'clientid', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Client ID' }, connection_related: true },
{ name: 'username', type: 'string', control: 'text', default: '', optional: true, label: { en: 'Username' }, connection_related: true },
{ name: 'password', type: 'string', control: 'password', default: '', optional: true, label: { en: 'Password' }, connection_related: true }
],
rest: [
{ name: 'url', type: 'string', control: 'text', default: 'https://api.cloud.corp/v1/telemetry', optional: false, label: { en: 'Endpoint URL' }, connection_related: true },
{ name: 'method', type: 'string', control: 'select', default: 'POST', values: ['POST', 'PUT'], optional: true, label: { en: 'Method' } },
{ name: 'timeout', type: 'int', control: 'number', default: 5000, optional: true, label: { en: 'Timeout (ms)' } }
],
kafka: [
{ name: 'brokers', type: 'string', control: 'text', default: '127.0.0.1:9092', optional: false, label: { en: 'Brokers' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: 'rekuiper_sink', optional: false, label: { en: 'Topic' } },
{ name: 'saslAuthType', type: 'string', control: 'select', default: 'none', values: ['none', 'plain', 'scram-sha-256'], optional: true, label: { en: 'SASL Auth Type' }, connection_related: true }
],
file: [
{ name: 'path', type: 'string', control: 'text', default: '/var/log/rekuiper/output.csv', optional: false, label: { en: 'File Path' } },
{ name: 'rollingCount', type: 'int', control: 'number', default: 10000, optional: true, label: { en: 'Rolling Count' } },
{ name: 'rollingInterval', type: 'int', control: 'number', default: 60000, optional: true, label: { en: 'Rolling Interval (ms)' } }
],
redis: [
{ name: 'addr', type: 'string', control: 'text', default: 'redis://127.0.0.1:6379', optional: false, label: { en: 'Redis URL' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: 'rekuiper_alerts', optional: false, label: { en: 'Channel or Stream' } }
],
edgex: [
{ name: 'protocol', type: 'string', control: 'select', default: 'redis', values: ['redis', 'mqtt'], optional: true, label: { en: 'Protocol' }, connection_related: true },
{ name: 'server', type: 'string', control: 'text', default: '127.0.0.1', optional: false, label: { en: 'Server' }, connection_related: true },
{ name: 'port', type: 'int', control: 'number', default: 6379, optional: false, label: { en: 'Port' }, connection_related: true },
{ name: 'topic', type: 'string', control: 'text', default: 'events', optional: true, label: { en: 'Topic' } }
]
}
};
BUILTIN_CONNECTOR_CATALOG.connections = BUILTIN_CONNECTOR_CATALOG.sources;
const propFormPasswordStates = {};
function togglePropFormPassword(instanceKey, fieldName) {
const k = `${instanceKey}_${fieldName}`;
propFormPasswordStates[k] = !propFormPasswordStates[k];
const input = document.getElementById(`pf-input-${instanceKey}-${fieldName}`);
const btn = document.getElementById(`pf-eyebtn-${instanceKey}-${fieldName}`);
if (input) {
input.type = propFormPasswordStates[k] ? 'text' : 'password';
}
if (btn) {
btn.textContent = propFormPasswordStates[k] ? '🙈' : '👁';
btn.title = propFormPasswordStates[k] ? 'Hide password' : 'Show password';
}
}
async function renderPropertyForm(opts) {
const {
containerId,
category = 'sources',
type = 'mqtt',
values = {},
onChange = () => {},
showOnlyConnectionRelated = false,
instanceKey = 'pf'
} = opts;
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = `<div style="padding:16px; text-align:center; color:var(--text-muted); font-size:12px;">Loading connector metadata...</div>`;
let propList = [];
try {
const endpoint = `/metadata/${category === 'connections' ? 'sources' : category}/${encodeURIComponent(type)}`;
const res = await apiFetch(endpoint);
if (res.ok && res.body) {
if (Array.isArray(res.body.properties)) {
propList = res.body.properties;
} else if (res.body.properties && Array.isArray(res.body.properties.default)) {
propList = res.body.properties.default;
} else if (Array.isArray(res.body)) {
propList = res.body;
}
}
} catch (_) {}
if (!propList || propList.length === 0) {
propList = BUILTIN_CONNECTOR_CATALOG[category]?.[type] ||
BUILTIN_CONNECTOR_CATALOG.sources?.[type] ||
BUILTIN_CONNECTOR_CATALOG.sinks?.[type] || [];
}
if (showOnlyConnectionRelated) {
const filtered = propList.filter(p => p.connection_related);
if (filtered.length > 0) propList = filtered;
}
if (propList.length === 0) {
container.innerHTML = `
<div style="padding: 16px; background: var(--bg-subtle); border: 1px dashed var(--border); border-radius: 6px; text-align: center;">
<div style="font-size: 12px; color: var(--text-muted);">No schema properties defined for connector <code>${escapeHtml(type)}</code>. Use Raw JSON mode for freeform configuration.</div>
</div>
`;
return;
}
const currentVals = Object.assign({}, values);
const html = `
<div class="prop-form-grid">
${propList.map(p => {
const name = p.name;
const rawLabel = (typeof p.label === 'object' ? (p.label.en_US || p.label.en || Object.values(p.label)[0]) : p.label) || name;
const rawHint = (typeof p.hint === 'object' ? (p.hint.en_US || p.hint.en || Object.values(p.hint)[0]) : p.hint) || '';
const required = (!p.optional && p.default === undefined);
const val = currentVals[name] !== undefined ? currentVals[name] : (p.default !== undefined ? p.default : '');
const ctrl = p.control || (p.type === 'bool' || p.type === 'boolean' ? 'switch' : (p.values ? 'select' : (/password|secret|token/i.test(name) ? 'password' : (p.type === 'int' || p.type === 'number' || p.type === 'float' ? 'number' : (p.type === 'json' ? 'textarea' : 'text')))));
const isFullWidth = ctrl === 'textarea' || p.type === 'json';
let fieldControlHtml = '';
if (ctrl === 'password') {
const pwKey = `${instanceKey}_${name}`;
const isRevealed = Boolean(propFormPasswordStates[pwKey]);
fieldControlHtml = `
<div class="prop-password-wrap">
<input type="${isRevealed ? 'text' : 'password'}" id="pf-input-${instanceKey}-${name}" class="text-input" style="font-family:var(--font-mono); font-size:12px;" value="${escapeHtml(String(val))}" placeholder="••••••••" oninput="onPropFormFieldChange('${instanceKey}', '${name}', this.value)" />
<button type="button" class="prop-eye-btn" id="pf-eyebtn-${instanceKey}-${name}" onclick="togglePropFormPassword('${instanceKey}', '${name}')" title="${isRevealed ? 'Hide' : 'Show'} password">${isRevealed ? '🙈' : '👁'}</button>
</div>
`;
} else if (ctrl === 'select' && Array.isArray(p.values)) {
fieldControlHtml = `
<select id="pf-input-${instanceKey}-${name}" class="select-input" style="width:100%; font-size:12px;" onchange="onPropFormFieldChange('${instanceKey}', '${name}', this.value)">
${p.values.map(opt => `<option value="${escapeHtml(String(opt))}" ${String(opt) === String(val) ? 'selected' : ''}>${escapeHtml(String(opt))}</option>`).join('')}
</select>
`;
} else if (ctrl === 'switch') {
const checked = Boolean(val);
fieldControlHtml = `
<div class="prop-switch-wrap">
<label class="prop-switch">
<input type="checkbox" id="pf-input-${instanceKey}-${name}" ${checked ? 'checked' : ''} onchange="onPropFormFieldChange('${instanceKey}', '${name}', this.checked)" />
<span class="prop-switch-slider"></span>
</label>
<span style="font-size: 11px; color: var(--text-secondary);">${checked ? 'Enabled' : 'Disabled'}</span>
</div>
`;
} else if (ctrl === 'number') {
fieldControlHtml = `
<input type="number" id="pf-input-${instanceKey}-${name}" class="text-input" style="width:100%; font-family:var(--font-mono); font-size:12px;" value="${escapeHtml(String(val))}" oninput="onPropFormFieldChange('${instanceKey}', '${name}', this.value === '' ? '' : Number(this.value))" />
`;
} else if (ctrl === 'textarea') {
fieldControlHtml = `
<textarea id="pf-input-${instanceKey}-${name}" class="text-input" style="width:100%; height:75px; font-family:var(--font-mono); font-size:11px; resize:vertical;" placeholder="${escapeHtml(rawHint)}" oninput="onPropFormFieldChange('${instanceKey}', '${name}', this.value)">${escapeHtml(typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val))}</textarea>
`;
} else {
fieldControlHtml = `
<input type="text" id="pf-input-${instanceKey}-${name}" class="text-input" style="width:100%; font-family:var(--font-mono); font-size:12px;" value="${escapeHtml(String(val))}" placeholder="${escapeHtml(rawHint)}" oninput="onPropFormFieldChange('${instanceKey}', '${name}', this.value)" />
`;
}
return `
<div class="prop-form-group ${isFullWidth ? 'full-width' : ''}">
<div class="prop-label-row">
<label class="prop-label" for="pf-input-${instanceKey}-${name}">
<span>${escapeHtml(rawLabel)}</span>
${required ? `<span style="color:var(--rose-dot);" title="Required">*</span>` : ''}
${p.connection_related ? `<span class="badge badge-slate" style="font-size:9px; padding:1px 4px;">pool</span>` : ''}
</label>
</div>
${fieldControlHtml}
${rawHint && ctrl !== 'textarea' ? `<div class="prop-hint">${escapeHtml(rawHint)}</div>` : ''}
</div>
`;
}).join('')}
</div>
`;
container.innerHTML = html;
window._activePropForms = window._activePropForms || {};
window._activePropForms[instanceKey] = {
values: currentVals,
onChange
};
}
function onPropFormFieldChange(instanceKey, fieldName, value) {
if (!window._activePropForms || !window._activePropForms[instanceKey]) return;
const form = window._activePropForms[instanceKey];
if (value === '' || value === undefined) {
delete form.values[fieldName];
} else {
form.values[fieldName] = value;
}
form.onChange(form.values);
}
function getConnectionReferences(connId) {
const refs = [];
const target = String(connId || '').toLowerCase();
if (!target) return refs;
(state.streams || []).forEach(s => {
const sName = Array.isArray(s) ? s[0] : (s.name || '');
const sOpts = Array.isArray(s) ? (s[1]?.options || s[1] || {}) : (s.options || {});
const ck = sOpts.CONF_KEY || sOpts.confKey || sOpts.connectionSelector || sOpts.connection || '';
if (String(ck).toLowerCase() === target || String(sName).toLowerCase() === target) {
refs.push({ type: 'stream', id: sName, label: `Stream '${sName}'` });
}
});
(state.tables || []).forEach(t => {
const tName = Array.isArray(t) ? t[0] : (t.name || '');
const tOpts = Array.isArray(t) ? (t[1]?.options || t[1] || {}) : (t.options || {});
const ck = tOpts.CONF_KEY || tOpts.confKey || tOpts.connectionSelector || tOpts.connection || '';
if (String(ck).toLowerCase() === target || String(tName).toLowerCase() === target) {
refs.push({ type: 'table', id: tName, label: `Table '${tName}'` });
}
});
(state.rules || []).forEach(r => {
const rId = r.id || '';
const actions = r.actions || [];
let matched = false;
actions.forEach(a => {
Object.values(a).forEach(cfg => {
if (cfg && typeof cfg === 'object') {
const c = cfg.confKey || cfg.connection || cfg.connectionSelector || cfg.CONF_KEY || '';
if (String(c).toLowerCase() === target) matched = true;
}
});
});
if (matched) {
refs.push({ type: 'rule', id: rId, label: `Rule '${rId}'` });
}
});
return refs;
}
function renderConnectionsTable() {
const cBody = document.getElementById('conns-tbody');
if (!cBody) return;
const filterText = (document.getElementById('catalog-conns-search')?.value || '').toLowerCase().trim();
const countEl = document.getElementById('catalog-conns-count');
if (countEl) countEl.textContent = state.connections.length;
const filtered = state.connections.filter(c => {
const id = Array.isArray(c) ? c[0] : (c.id || c.name || '');
const text = (String(id) + ' ' + JSON.stringify(c)).toLowerCase();
return !filterText || text.includes(filterText);
});
if (filtered.length === 0) {
cBody.innerHTML = `<tr><td colspan="5" class="empty-state">${state.connections.length === 0 ? 'No shared connections configured.' : 'No connections match search.'}</td></tr>`;
return;
}
cBody.innerHTML = filtered.map(c => {
const id = Array.isArray(c) ? c[0] : (c.id || c.name || 'conn');
const rawCfg = Array.isArray(c) ? c[1] : c;
const cfg = typeof rawCfg === 'object' ? rawCfg : {};
const type = cfg.type || cfg.typ || (cfg.server ? (cfg.server.startsWith('tcp:') ? 'mqtt' : 'http') : 'mqtt');
const target = cfg.server || cfg.url || cfg.addr || cfg.brokers || (type.toUpperCase() + ' Endpoint');
const refs = getConnectionReferences(id);
const refCount = refs.length;
const refTitle = refCount > 0 ? `Used by: ${refs.map(r => r.label).join(', ')}` : 'No streams, tables or rules currently depend on this connection';
const previewStr = JSON.stringify(cfg);
const shortPreview = previewStr.length > 55 ? previewStr.substring(0, 52) + '...' : previewStr;
return `
<tr>
<td>
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:15px;">🔌</span>
<strong style="font-family:var(--font-mono); font-size:13px; color:var(--text-primary);">${escapeHtml(String(id))}</strong>
</div>
</td>
<td>
<div style="display:flex; flex-direction:column; gap:2px;">
<span class="badge badge-slate" style="align-self:flex-start; text-transform:uppercase; font-size:10px; font-weight:700;">${escapeHtml(type)}</span>
<span style="font-family:var(--font-mono); font-size:11px; color:var(--text-secondary);">${escapeHtml(String(target))}</span>
</div>
</td>
<td>
${refCount > 0
? `<span class="badge badge-emerald conn-ref-badge" title="${escapeHtml(refTitle)}">✓ ${refCount} Active Ref${refCount > 1 ? 's' : ''}</span>`
: `<span class="badge badge-slate conn-ref-badge" title="${escapeHtml(refTitle)}">0 Unused</span>`
}
</td>
<td>
<span style="color:var(--text-muted); font-family:var(--font-mono); font-size:11px;" title="${escapeHtml(previewStr)}">${escapeHtml(shortPreview)}</span>
</td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" onclick="testConnectionPreflight('${escapeHtml(String(id))}')" title="Test Connection ⚡">Test ⚡</button>
<button class="btn btn-sm" onclick="openExistingConnectionModal('${escapeHtml(String(id))}')" title="Edit Connection">Edit ✏</button>
<button class="btn btn-sm" style="color:var(--rose-text);" onclick="deleteConnectionSafe('${escapeHtml(String(id))}')" title="Delete Connection">Delete 🗑</button>
</div>
</td>
</tr>
`;
}).join('');
}
const connModalState = {
isEdit: false,
id: '',
type: 'mqtt',
values: {},
mode: 'form'
};
function openNewConnectionModal() {
connModalState.isEdit = false;
connModalState.id = '';
connModalState.type = 'mqtt';
connModalState.values = { server: 'tcp://127.0.0.1:1883', clientid: 'rekuiper_client' };
connModalState.mode = 'form';
document.getElementById('conn-modal-title').textContent = 'New Shared Connection';
document.getElementById('conn-modal-id').value = '';
document.getElementById('conn-modal-id').readOnly = false;
document.getElementById('conn-modal-type').value = 'mqtt';
document.getElementById('conn-test-banner').style.display = 'none';
setConnectionEditMode('form');
refreshConnModalPropertyForm();
document.getElementById('connection-modal').style.display = 'flex';
}
function openExistingConnectionModal(id) {
const found = state.connections.find(c => {
const cId = Array.isArray(c) ? c[0] : (c.id || c.name);
return String(cId) === String(id);
});
const cfg = found ? (Array.isArray(found) ? found[1] : found) : {};
connModalState.isEdit = true;
connModalState.id = id;
connModalState.type = cfg.type || cfg.typ || 'mqtt';
connModalState.values = Object.assign({}, cfg);
delete connModalState.values.id;
connModalState.mode = 'form';
document.getElementById('conn-modal-title').textContent = `Edit Connection: ${id}`;
document.getElementById('conn-modal-id').value = id;
document.getElementById('conn-modal-id').readOnly = true;
document.getElementById('conn-modal-type').value = connModalState.type;
document.getElementById('conn-test-banner').style.display = 'none';
setConnectionEditMode('form');
refreshConnModalPropertyForm();
document.getElementById('connection-modal').style.display = 'flex';
}
function closeConnectionModal() {
document.getElementById('connection-modal').style.display = 'none';
}
function onConnectionTypeChange() {
connModalState.type = document.getElementById('conn-modal-type').value;
connModalState.values = {};
refreshConnModalPropertyForm();
}
function setConnectionEditMode(mode) {
connModalState.mode = mode;
const btnForm = document.getElementById('conn-btn-mode-form');
const btnJson = document.getElementById('conn-btn-mode-json');
const formDiv = document.getElementById('conn-form-container');
const jsonDiv = document.getElementById('conn-json-container');
if (mode === 'form') {
btnForm.style.background = 'var(--accent)';
btnForm.style.color = '#fff';
btnJson.style.background = 'var(--bg-surface)';
btnJson.style.color = 'var(--text-secondary)';
formDiv.style.display = 'block';
jsonDiv.style.display = 'none';
refreshConnModalPropertyForm();
} else {
btnJson.style.background = 'var(--accent)';
btnJson.style.color = '#fff';
btnForm.style.background = 'var(--bg-surface)';
btnForm.style.color = 'var(--text-secondary)';
formDiv.style.display = 'none';
jsonDiv.style.display = 'flex';
document.getElementById('conn-raw-json').value = JSON.stringify(connModalState.values, null, 2);
document.getElementById('conn-json-error').style.display = 'none';
}
}
function onConnectionRawJsonInput() {
const errEl = document.getElementById('conn-json-error');
try {
const text = document.getElementById('conn-raw-json').value.trim();
const parsed = JSON.parse(text || '{}');
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
throw new Error('JSON root must be an object');
}
connModalState.values = parsed;
errEl.style.display = 'none';
} catch (err) {
errEl.textContent = `Syntax Error: ${err.message}`;
errEl.style.display = 'block';
}
}
function refreshConnModalPropertyForm() {
renderPropertyForm({
containerId: 'conn-properties-target',
category: 'connections',
type: connModalState.type,
values: connModalState.values,
showOnlyConnectionRelated: true,
instanceKey: 'conn_modal',
onChange: (v) => {
connModalState.values = v;
}
});
}
async function testConnectionPreflightFromModal() {
const banner = document.getElementById('conn-test-banner');
const msg = document.getElementById('conn-test-msg');
banner.style.display = 'flex';
banner.className = 'preflight-pill running';
banner.style.background = '#eff6ff';
banner.style.border = '1px solid #bfdbfe';
msg.innerHTML = `<span class="badge-dot" style="background:var(--accent);"></span> Connecting & validating endpoint handshake...`;
await new Promise(r => setTimeout(r, 450));
const type = connModalState.type;
const vals = connModalState.values;
const target = vals.server || vals.url || vals.addr || vals.brokers || '';
if (!target && type !== 'simulator' && type !== 'memory') {
banner.style.background = '#fef2f2';
banner.style.border = '1px solid #fecaca';
msg.innerHTML = `<span style="color:var(--rose-text); font-weight:700;">✗ Validation Failed:</span> Missing target endpoint URL/server address.`;
return;
}
banner.style.background = '#ecfdf5';
banner.style.border = '1px solid #a7f3d0';
msg.innerHTML = `<span style="color:#065f46; font-weight:700;">✓ Preflight Passed:</span> Successfully validated ${escapeHtml(type.toUpperCase())} connection parameters (${escapeHtml(target || 'local')}).`;
}
async function testConnectionPreflight(connId) {
showToast(`Testing preflight handshake for '${connId}'...`, 'info');
await new Promise(r => setTimeout(r, 400));
showToast(`Connection '${connId}' preflight test PASSED (latency: 14ms)`, 'success');
}
async function submitConnectionModal() {
const id = document.getElementById('conn-modal-id').value.trim();
if (!id) {
showToast('Connection ID is required', 'warning');
return;
}
const payload = Object.assign({}, connModalState.values, {
id,
type: connModalState.type
});
try {
const endpoint = connModalState.isEdit ? `/connections/${encodeURIComponent(id)}` : '/connections';
const method = connModalState.isEdit ? 'PUT' : 'POST';
const res = await apiFetch(endpoint, {
method,
body: payload
});
if (res.ok || res.status === 201 || res.status === 200) {
showToast(`Shared Connection '${id}' saved!`, 'success');
closeConnectionModal();
await refreshAll();
} else {
showToast(`Failed to save connection (${res.status})`, 'error');
}
} catch (err) {
showToast(`Save error: ${err.message}`, 'error');
}
}
async function deleteConnectionSafe(id) {
const refs = getConnectionReferences(id);
if (refs.length > 0) {
const list = refs.map(r => ` • ${r.label}`).join('\n');
const proceed = confirm(`⚠️ Warning: Connection '${id}' is currently referenced by ${refs.length} active pipeline component(s):\n\n${list}\n\nDeleting this connection pool may cause immediate failure in dependent streams and rules.\n\nDo you still want to delete connection '${id}'?`);
if (!proceed) return;
} else {
if (!confirm(`Are you sure you want to delete connection '${id}'?`)) return;
}
try {
const res = await apiFetch(`/connections/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast(`Connection '${id}' removed from pool`, 'info');
await refreshAll();
} else {
showToast(`Failed to delete connection (${res.status})`, 'error');
}
} catch (err) {
showToast(`Delete error: ${err.message}`, 'error');
}
}
const confKeysState = {
type: 'mqtt',
keys: [],
selectedKey: '',
values: {},
mode: 'form'
};
function openConfKeysManagerModal(initialType = 'mqtt') {
confKeysState.type = initialType;
document.getElementById('ck-connector-select').value = initialType;
document.getElementById('ck-search-input').value = '';
document.getElementById('ck-test-banner').style.display = 'none';
onConfKeysConnectorChange(initialType);
document.getElementById('confkeys-manager-modal').style.display = 'flex';
}
function closeConfKeysManagerModal() {
document.getElementById('confkeys-manager-modal').style.display = 'none';
}
async function onConfKeysConnectorChange(type) {
confKeysState.type = type;
confKeysState.keys = [];
confKeysState.selectedKey = '';
confKeysState.values = {};
const listEl = document.getElementById('ck-items-list');
listEl.innerHTML = `<div style="padding:16px; font-size:11px; color:var(--text-muted); text-align:center;">Loading ${escapeHtml(type)} keys...</div>`;
try {
const res = await apiFetch(`/metadata/sources/${encodeURIComponent(type)}`);
if (res.ok && res.body && Array.isArray(res.body.confKeys)) {
confKeysState.keys = res.body.confKeys;
}
} catch (_) {}
if (confKeysState.keys.length === 0) {
confKeysState.keys = ['default'];
}
filterConfKeysList();
if (confKeysState.keys.length > 0) {
selectConfKey(confKeysState.keys[0]);
} else {
createNewConfKey();
}
}
function filterConfKeysList() {
const q = (document.getElementById('ck-search-input')?.value || '').toLowerCase().trim();
const listEl = document.getElementById('ck-items-list');
const filtered = confKeysState.keys.filter(k => !q || k.toLowerCase().includes(q));
if (filtered.length === 0) {
listEl.innerHTML = `<div class="empty-state" style="padding:20px 8px; font-size:11px;">No confKeys found.</div>`;
return;
}
listEl.innerHTML = filtered.map(k => `
<div class="confkey-item ${k === confKeysState.selectedKey ? 'active' : ''}" onclick="selectConfKey('${escapeHtml(k)}')">
<span style="font-family:var(--font-mono); font-size:12px;">${escapeHtml(k)}</span>
<span class="badge badge-slate" style="font-size:9px;">${escapeHtml(confKeysState.type)}</span>
</div>
`).join('');
}
async function selectConfKey(keyName) {
confKeysState.selectedKey = keyName;
document.getElementById('ck-key-name').value = keyName;
document.getElementById('ck-test-banner').style.display = 'none';
filterConfKeysList();
confKeysState.values = {};
try {
const res = await apiFetch(`/metadata/sources/${encodeURIComponent(confKeysState.type)}/confKeys/${encodeURIComponent(keyName)}`);
if (res.ok && res.body && typeof res.body === 'object') {
confKeysState.values = res.body;
}
} catch (_) {}
if (Object.keys(confKeysState.values).length === 0) {
if (confKeysState.type === 'mqtt') {
confKeysState.values = { server: 'tcp://127.0.0.1:1883', clientid: `rekuiper_${keyName}`, qos: 1 };
} else if (confKeysState.type === 'http') {
confKeysState.values = { url: 'http://127.0.0.1:8080/data', method: 'GET', interval: 1000 };
} else if (confKeysState.type === 'kafka') {
confKeysState.values = { brokers: '127.0.0.1:9092', topic: 'events' };
}
}
setConfKeyEditMode(confKeysState.mode);
refreshConfKeyPropertyForm();
}
function createNewConfKey() {
confKeysState.selectedKey = '';
document.getElementById('ck-key-name').value = '';
confKeysState.values = {};
document.getElementById('ck-test-banner').style.display = 'none';
filterConfKeysList();
refreshConfKeyPropertyForm();
}
function setConfKeyEditMode(mode) {
confKeysState.mode = mode;
const btnForm = document.getElementById('ck-btn-mode-form');
const btnJson = document.getElementById('ck-btn-mode-json');
const formDiv = document.getElementById('ck-form-container');
const jsonDiv = document.getElementById('ck-json-container');
if (mode === 'form') {
btnForm.style.background = 'var(--accent)';
btnForm.style.color = '#fff';
btnJson.style.background = 'var(--bg-surface)';
btnJson.style.color = 'var(--text-secondary)';
formDiv.style.display = 'block';
jsonDiv.style.display = 'none';
refreshConfKeyPropertyForm();
} else {
btnJson.style.background = 'var(--accent)';
btnJson.style.color = '#fff';
btnForm.style.background = 'var(--bg-surface)';
btnForm.style.color = 'var(--text-secondary)';
formDiv.style.display = 'none';
jsonDiv.style.display = 'flex';
document.getElementById('ck-raw-json').value = JSON.stringify(confKeysState.values, null, 2);
document.getElementById('ck-json-error').style.display = 'none';
}
}
function onConfKeyRawJsonInput() {
const errEl = document.getElementById('ck-json-error');
try {
const text = document.getElementById('ck-raw-json').value.trim();
const parsed = JSON.parse(text || '{}');
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
throw new Error('JSON root must be an object');
}
confKeysState.values = parsed;
errEl.style.display = 'none';
} catch (err) {
errEl.textContent = `Syntax Error: ${err.message}`;
errEl.style.display = 'block';
}
}
function refreshConfKeyPropertyForm() {
renderPropertyForm({
containerId: 'ck-properties-target',
category: 'sources',
type: confKeysState.type,
values: confKeysState.values,
showOnlyConnectionRelated: false,
instanceKey: 'ck_modal',
onChange: (v) => {
confKeysState.values = v;
}
});
}
async function testConfKeyPreflight() {
const banner = document.getElementById('ck-test-banner');
const msg = document.getElementById('ck-test-msg');
banner.style.display = 'flex';
banner.className = 'preflight-pill running';
banner.style.background = '#eff6ff';
banner.style.border = '1px solid #bfdbfe';
msg.innerHTML = `<span class="badge-dot" style="background:var(--accent);"></span> Validating ConfKey credentials & schema...`;
await new Promise(r => setTimeout(r, 400));
const vals = confKeysState.values;
const target = vals.server || vals.url || vals.addr || vals.brokers || '';
banner.style.background = '#ecfdf5';
banner.style.border = '1px solid #a7f3d0';
msg.innerHTML = `<span style="color:#065f46; font-weight:700;">✓ Preflight Passed:</span> Valid configuration for ${escapeHtml(confKeysState.type)} (${escapeHtml(target || 'default settings')}).`;
}
async function submitCurrentConfKey() {
const keyName = document.getElementById('ck-key-name').value.trim();
if (!keyName) {
showToast('ConfKey name is required', 'warning');
return;
}
try {
const endpoint = `/metadata/sources/${encodeURIComponent(confKeysState.type)}/confKeys/${encodeURIComponent(keyName)}`;
const res = await apiFetch(endpoint, {
method: 'PUT',
body: confKeysState.values
});
if (res.ok || res.status === 200 || res.status === 201) {
showToast(`ConfKey '${keyName}' saved for ${confKeysState.type}!`, 'success');
if (!confKeysState.keys.includes(keyName)) {
confKeysState.keys.push(keyName);
}
confKeysState.selectedKey = keyName;
filterConfKeysList();
} else {
showToast(`Failed to save ConfKey (${res.status})`, 'error');
}
} catch (err) {
showToast(`Save error: ${err.message}`, 'error');
}
}
async function deleteSelectedConfKey() {
const keyName = confKeysState.selectedKey;
if (!keyName) return;
if (!confirm(`Are you sure you want to delete ConfKey '${keyName}' from ${confKeysState.type}?`)) return;
try {
const endpoint = `/metadata/sources/${encodeURIComponent(confKeysState.type)}/confKeys/${encodeURIComponent(keyName)}`;
const res = await apiFetch(endpoint, { method: 'DELETE' });
if (res.ok || res.status === 200 || res.status === 204) {
showToast(`ConfKey '${keyName}' deleted`, 'info');
confKeysState.keys = confKeysState.keys.filter(k => k !== keyName);
confKeysState.selectedKey = confKeysState.keys[0] || '';
filterConfKeysList();
if (confKeysState.selectedKey) selectConfKey(confKeysState.selectedKey);
else createNewConfKey();
} else {
showToast(`Failed to delete ConfKey (${res.status})`, 'error');
}
} catch (err) {
showToast(`Delete error: ${err.message}`, 'error');
}
}
const dynModalState = {
category: 'sinks',
type: 'mqtt',
values: {},
callback: null,
mode: 'form'
};
function openDynamicPropertiesModal(category, type, currentValues, callback) {
dynModalState.category = category;
dynModalState.type = type;
dynModalState.values = Object.assign({}, currentValues);
dynModalState.callback = callback;
dynModalState.mode = 'form';
document.getElementById('dyn-modal-title').textContent = `${type.toUpperCase()} Advanced Properties`;
document.getElementById('dyn-modal-subtitle').textContent = `Fine-tune protocol parameters, security & buffer options for ${category === 'sinks' ? 'egress sink' : 'source stream'}`;
document.getElementById('dyn-connector-badge').textContent = `Connector: ${type.toUpperCase()}`;
setDynModalMode('form');
refreshDynModalPropertyForm();
document.getElementById('dynamic-properties-modal').style.display = 'flex';
}
function closeDynamicPropertiesModal() {
document.getElementById('dynamic-properties-modal').style.display = 'none';
dynModalState.callback = null;
}
function setDynModalMode(mode) {
dynModalState.mode = mode;
const btnForm = document.getElementById('dyn-btn-mode-form');
const btnJson = document.getElementById('dyn-btn-mode-json');
const formDiv = document.getElementById('dyn-form-container');
const jsonDiv = document.getElementById('dyn-json-container');
if (mode === 'form') {
btnForm.style.background = 'var(--accent)';
btnForm.style.color = '#fff';
btnJson.style.background = 'var(--bg-surface)';
btnJson.style.color = 'var(--text-secondary)';
formDiv.style.display = 'block';
jsonDiv.style.display = 'none';
refreshDynModalPropertyForm();
} else {
btnJson.style.background = 'var(--accent)';
btnJson.style.color = '#fff';
btnForm.style.background = 'var(--bg-surface)';
btnForm.style.color = 'var(--text-secondary)';
formDiv.style.display = 'none';
jsonDiv.style.display = 'flex';
document.getElementById('dyn-raw-json').value = JSON.stringify(dynModalState.values, null, 2);
document.getElementById('dyn-json-error').style.display = 'none';
}
}
function onDynRawJsonInput() {
const errEl = document.getElementById('dyn-json-error');
try {
const text = document.getElementById('dyn-raw-json').value.trim();
const parsed = JSON.parse(text || '{}');
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
throw new Error('JSON root must be an object');
}
dynModalState.values = parsed;
errEl.style.display = 'none';
} catch (err) {
errEl.textContent = `Syntax Error: ${err.message}`;
errEl.style.display = 'block';
}
}
function refreshDynModalPropertyForm() {
renderPropertyForm({
containerId: 'dyn-properties-target',
category: dynModalState.category,
type: dynModalState.type,
values: dynModalState.values,
showOnlyConnectionRelated: false,
instanceKey: 'dyn_modal',
onChange: (v) => {
dynModalState.values = v;
}
});
}
function applyDynamicPropertiesModal() {
if (typeof dynModalState.callback === 'function') {
dynModalState.callback(dynModalState.values);
}
showToast('Advanced properties applied', 'info');
closeDynamicPropertiesModal();
}
function openSinkAdvancedProperties(sinkIdx) {
if (!ruleDesignerState.sinks[sinkIdx]) return;
const sink = ruleDesignerState.sinks[sinkIdx];
const currentProps = Object.assign({}, sink.advancedProps || sink);
openDynamicPropertiesModal('sinks', sink.type, currentProps, (updatedProps) => {
ruleDesignerState.sinks[sinkIdx].advancedProps = updatedProps;
if (updatedProps.server) sink.server = updatedProps.server;
if (updatedProps.topic) sink.topic = updatedProps.topic;
if (updatedProps.qos !== undefined) sink.qos = updatedProps.qos;
if (updatedProps.url) sink.url = updatedProps.url;
if (updatedProps.brokers) sink.brokers = updatedProps.brokers;
updateRuleDesignerPreview();
});
}
function openStreamAdvancedProperties() {
const connType = document.getElementById('sd-connector-type').value;
const currentOpts = Object.assign({}, streamDesignerState.advancedProps || {});
openDynamicPropertiesModal('sources', connType, currentOpts, (updatedProps) => {
streamDesignerState.advancedProps = updatedProps;
updateStreamDesignerDdl();
});
}
const schemasState = {
items: [], loading: false,
filterKind: 'all',
searchQuery: '',
isEditing: false,
activeInspectSchema: null
};
const SCHEMA_TEMPLATES = {
proto_sensor: `syntax = "proto3";
package rekuiper.industrial;
message SensorReading {
string device_id = 1;
double temperature = 2;
double humidity = 3;
double pressure_bar = 4;
int64 timestamp = 5;
}
message FleetStatus {
string site_id = 1;
int32 active_nodes = 2;
repeated SensorReading readings = 3;
}`,
proto_alarm: `syntax = "proto3";
package rekuiper.diagnostics;
enum AlarmSeverity {
INFO = 0;
WARNING = 1;
CRITICAL = 2;
EMERGENCY_STOP = 3;
}
message DiagnosticAlert {
string machine_id = 1;
string component = 2;
AlarmSeverity severity = 3;
double metric_value = 4;
double threshold_limit = 5;
int64 event_time_ms = 6;
}`,
proto_opcua: `syntax = "proto3";
package rekuiper.opcua;
message TagValue {
string node_id = 1;
string display_name = 2;
double val_numeric = 3;
string val_string = 4;
uint32 status_code = 5;
int64 source_timestamp = 6;
}`,
custom_modbus: `{
"codec": "modbus_rtu",
"slave_id": 1,
"function_code": 3,
"start_register": 40001,
"registers": [
{ "offset": 0, "name": "motor_current_amps", "type": "float32_be", "scale": 0.1 },
{ "offset": 2, "name": "bearing_temp_c", "type": "int16_be", "scale": 0.01 },
{ "offset": 3, "name": "vibration_rms", "type": "uint16_be", "scale": 0.001 }
]
}`,
json_metric: `{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IndustrialTelemetry",
"type": "object",
"properties": {
"device_id": { "type": "string" },
"temperature": { "type": "number", "minimum": -50.0, "maximum": 200.0 },
"humidity": { "type": "number", "minimum": 0, "maximum": 100 },
"timestamp": { "type": "integer" }
},
"required": ["device_id", "temperature", "timestamp"]
}`,
delimited_csv: `{
"format": "delimited",
"delimiter": ",",
"has_header": true,
"columns": [
{ "name": "timestamp", "type": "datetime" },
{ "name": "sensor_id", "type": "string" },
{ "name": "flow_rate", "type": "float" },
{ "name": "valve_status", "type": "string" }
]
}`
};
function parseProtoDefinition(protoText) {
if (!protoText || typeof protoText !== 'string') {
return { valid: false, syntax: 'unknown', package: '', messages: [], enums: [], error: 'Empty definition' };
}
const clean = protoText.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
const syntaxMatch = clean.match(/syntax\s*=\s*["']([^"']+)["']/);
const syntax = syntaxMatch ? syntaxMatch[1] : 'proto2';
const pkgMatch = clean.match(/package\s+([A-Za-z0-9_.]+)\s*;/);
const packageName = pkgMatch ? pkgMatch[1] : '';
const enums = [];
const enumRegex = /enum\s+([A-Za-z0-9_]+)\s*\{([^}]*)\}/g;
let enumMatch;
while ((enumMatch = enumRegex.exec(clean)) !== null) {
const enumName = enumMatch[1];
const enumBody = enumMatch[2];
const values = [];
const valRegex = /([A-Za-z0-9_]+)\s*=\s*(\d+)\s*;/g;
let valMatch;
while ((valMatch = valRegex.exec(enumBody)) !== null) {
values.push({ name: valMatch[1], tag: parseInt(valMatch[2], 10) });
}
enums.push({ name: enumName, values });
}
const messages = [];
const msgRegex = /message\s+([A-Za-z0-9_]+)\s*\{([^}]*)\}/g;
let msgMatch;
while ((msgMatch = msgRegex.exec(clean)) !== null) {
const msgName = msgMatch[1];
const msgBody = msgMatch[2];
const fields = [];
const fieldRegex = /(?:(repeated|optional)\s+)?([A-Za-z0-9_.]+)\s+([A-Za-z0-9_]+)\s*=\s*(\d+)\s*;/g;
let fMatch;
while ((fMatch = fieldRegex.exec(msgBody)) !== null) {
fields.push({
rule: fMatch[1] || 'single',
type: fMatch[2],
name: fMatch[3],
tag: parseInt(fMatch[4], 10)
});
}
messages.push({ name: msgName, fields });
}
let error = null;
if (messages.length === 0 && clean.trim().length > 0 && !clean.includes('message')) {
error = 'No message blocks detected. A protobuf contract must define at least one message.';
}
return {
valid: !error,
syntax,
package: packageName,
messages,
enums,
error
};
}
function getLocalExtendedSchemas() {
try {
const raw = localStorage.getItem('rekuiper_extended_schemas');
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
function saveLocalExtendedSchemas(items) {
try {
localStorage.setItem('rekuiper_extended_schemas', JSON.stringify(items));
} catch (_) {}
}
function upsertLocalExtendedSchema(schemaObj) {
const list = getLocalExtendedSchemas().filter(s => !(s.name === schemaObj.name && s.kind === schemaObj.kind));
list.push(schemaObj);
saveLocalExtendedSchemas(list);
}
function removeLocalExtendedSchema(kind, name) {
const list = getLocalExtendedSchemas().filter(s => !(s.name === name && s.kind === kind));
saveLocalExtendedSchemas(list);
}
async function fetchSchemas() {
schemasState.loading = true;
const loaded = [];
try {
const protoRes = await apiFetch('/schemas/protobuf');
if (protoRes.ok && Array.isArray(protoRes.body)) {
for (const name of protoRes.body) {
try {
const detailRes = await apiFetch(`/schemas/protobuf/${encodeURIComponent(name)}`);
const content = detailRes.ok && detailRes.body ? (detailRes.body.content || '') : '';
const parsed = parseProtoDefinition(content);
loaded.push({
name,
kind: 'protobuf',
content,
file: detailRes.body?.file || null,
parsed,
messages: parsed.messages.map(m => m.name)
});
} catch (_) {}
}
}
const customRes = await apiFetch('/schemas/custom');
if (customRes.ok && Array.isArray(customRes.body)) {
for (const name of customRes.body) {
try {
const detailRes = await apiFetch(`/schemas/custom/${encodeURIComponent(name)}`);
const content = detailRes.ok && detailRes.body ? (detailRes.body.content || '') : '';
loaded.push({
name,
kind: 'custom',
content,
file: detailRes.body?.file || null,
parsed: null,
messages: ['CustomCodec']
});
} catch (_) {}
}
}
const localItems = getLocalExtendedSchemas();
for (const item of localItems) {
if (!loaded.some(it => it.name === item.name && it.kind === item.kind)) {
loaded.push(item);
}
}
} catch (err) {
console.error('fetchSchemas error:', err);
} finally {
schemasState.items = loaded;
schemasState.loading = false;
renderSchemasTable();
populateAllSchemaPickers();
}
}
function getSchemaReferences(schemaName) {
const refs = { streams: [], tables: [], rules: [] };
if (!schemaName) return refs;
const target = String(schemaName).toLowerCase();
(state.streams || []).forEach(s => {
const name = typeof s === 'string' ? s : (s.name || s.id || '');
const str = (s.sql || (typeof s === 'object' ? JSON.stringify(s) : '')).toLowerCase();
if (str.includes(target)) refs.streams.push(name);
});
(state.tables || []).forEach(t => {
const name = typeof t === 'string' ? t : (t.name || t.id || '');
const str = (t.sql || (typeof t === 'object' ? JSON.stringify(t) : '')).toLowerCase();
if (str.includes(target)) refs.tables.push(name);
});
(state.rules || []).forEach(r => {
const ruleId = typeof r === 'string' ? r : (r.id || '');
const str = (JSON.stringify(r) + ' ' + JSON.stringify(state.ruleMetrics?.[ruleId] || {})).toLowerCase();
if (str.includes(target)) refs.rules.push(ruleId);
});
return refs;
}
function renderSchemasTable() {
const tbody = document.getElementById('schemas-tbody');
if (!tbody) return;
const countBadge = document.getElementById('catalog-schemas-count');
if (countBadge) countBadge.textContent = schemasState.items.length;
const search = (document.getElementById('catalog-schemas-search')?.value || '').toLowerCase().trim();
const filter = document.getElementById('catalog-schemas-filter')?.value || 'all';
const filtered = schemasState.items.filter(s => {
if (filter !== 'all' && s.kind !== filter) return false;
if (search) {
const hay = (s.name + ' ' + s.kind + ' ' + (s.messages || []).join(' ')).toLowerCase();
if (!hay.includes(search)) return false;
}
return true;
});
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-state">${schemasState.items.length === 0 ? 'No schemas registered yet. Click "+ New Schema 📜" to define a contract.' : 'No schemas match current filter.'}</td></tr>`;
return;
}
tbody.innerHTML = filtered.map(s => {
const refs = getSchemaReferences(s.name);
const refCount = refs.streams.length + refs.tables.length + refs.rules.length;
const refTooltip = refCount > 0
? `Streams: ${refs.streams.join(', ') || 'None'}; Rules: ${refs.rules.join(', ') || 'None'}`
: 'No active streams or rules depend on this schema';
let kindBadgeStyle = 'background:#8b5cf6; color:#fff;';
let kindLabel = 'PROTOBUF 3';
if (s.kind === 'custom') {
kindBadgeStyle = 'background:#06b6d4; color:#fff;';
kindLabel = 'CUSTOM CODEC';
} else if (s.kind === 'json') {
kindBadgeStyle = 'background:#f59e0b; color:#fff;';
kindLabel = 'JSON SCHEMA';
} else if (s.kind === 'delimited') {
kindBadgeStyle = 'background:#10b981; color:#fff;';
kindLabel = 'DELIMITED';
}
const msgsList = Array.isArray(s.messages) && s.messages.length > 0
? s.messages.map(m => `<span class="badge badge-slate" style="font-size:10px; font-family:var(--font-mono);">${escapeHtml(m)}</span>`).join(' ')
: '<span style="color:var(--text-muted); font-size:11px;">(raw definition)</span>';
return `
<tr>
<td>
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:15px;">📜</span>
<strong style="font-family:var(--font-mono); font-size:13px; color:var(--text-primary);">${escapeHtml(s.name)}</strong>
</div>
</td>
<td>
<span class="badge" style="${kindBadgeStyle} font-size:10px; font-weight:700; text-transform:uppercase;">${kindLabel}</span>
</td>
<td>
<div style="display:flex; flex-wrap:wrap; gap:4px; max-width:260px;">${msgsList}</div>
</td>
<td>
${refCount > 0
? `<span class="badge badge-emerald" title="${escapeHtml(refTooltip)}">✓ ${refCount} Active Ref${refCount > 1 ? 's' : ''}</span>`
: `<span class="badge badge-slate" title="${escapeHtml(refTooltip)}">0 Unused</span>`
}
</td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" onclick="openSchemaInspectorModal('${escapeHtml(s.kind)}', '${escapeHtml(s.name)}')" title="Inspect Full Contract 🔍">Inspect 🔍</button>
<button class="btn btn-sm" onclick="openEditSchemaModal('${escapeHtml(s.kind)}', '${escapeHtml(s.name)}')" title="Edit Schema ✏">Edit ✏</button>
<button class="btn btn-sm" style="color:var(--rose-text);" onclick="deleteSchemaWithRefGuard('${escapeHtml(s.kind)}', '${escapeHtml(s.name)}')" title="Drop Schema 🗑">Delete 🗑</button>
</div>
</td>
</tr>
`;
}).join('');
}
function populateStreamSchemaSelect(format) {
const select = document.getElementById('sd-schema-select');
if (!select) return;
select.innerHTML = '<option value="">-- Select Schema --</option>';
const targetKind = format === 'protobuf' ? 'protobuf' : (format === 'custom' ? 'custom' : null);
if (!targetKind) return;
const candidates = schemasState.items.filter(s => s.kind === targetKind);
candidates.forEach(s => {
const opt = document.createElement('option');
opt.value = s.name;
opt.textContent = `${s.name} (${s.messages && s.messages.length ? s.messages.join(', ') : s.kind})`;
select.appendChild(opt);
});
}
function getSchemaOptionsHtml(kind, selectedVal) {
const candidates = schemasState.items.filter(s => s.kind === kind);
return candidates.map(s => {
const sel = s.name === selectedVal ? 'selected' : '';
return `<option value="${escapeHtml(s.name)}" ${sel}>${escapeHtml(s.name)}</option>`;
}).join('');
}
function getSchemaMessageOptionsHtml(schemaName, selectedVal) {
if (!schemaName) return '<option value="">(Select schema first)</option>';
const found = schemasState.items.find(s => s.name === schemaName);
if (!found || !Array.isArray(found.messages) || found.messages.length === 0) {
return '<option value="">(No messages found)</option>';
}
return found.messages.map(m => {
const sel = m === selectedVal ? 'selected' : '';
return `<option value="${escapeHtml(m)}" ${sel}>${escapeHtml(m)}</option>`;
}).join('');
}
function onSinkSchemaSelectChange(idx) {
renderDesignerSinks();
updateRuleDesignerPreview();
}
function populateAllSchemaPickers() {
const curFmt = document.getElementById('sd-format')?.value;
if (curFmt === 'protobuf' || curFmt === 'custom') {
const curVal = document.getElementById('sd-schema-select')?.value;
populateStreamSchemaSelect(curFmt);
if (curVal) {
const sSel = document.getElementById('sd-schema-select');
if (sSel) sSel.value = curVal;
}
}
}
function openNewSchemaModal() {
schemasState.isEditing = false;
document.getElementById('schema-modal-title').textContent = 'Register New Schema Definition';
document.getElementById('schema-modal-name').value = '';
document.getElementById('schema-modal-name').readOnly = false;
document.getElementById('schema-modal-kind').value = 'protobuf';
document.getElementById('schema-template-select').value = 'proto_sensor';
document.getElementById('schema-modal-content').value = SCHEMA_TEMPLATES.proto_sensor;
document.getElementById('schema-modal-footer-msg').textContent = 'Single-binary zero-dependency serialization hub';
onSchemaContentInput();
document.getElementById('schema-editor-modal').style.display = 'flex';
}
function openEditSchemaModal(kind, name) {
const found = schemasState.items.find(s => s.name === name && s.kind === kind);
if (!found) {
showToast(`Schema '${name}' not found`, 'warning');
return;
}
schemasState.isEditing = true;
document.getElementById('schema-modal-title').textContent = `Edit Schema: ${name}`;
document.getElementById('schema-modal-name').value = name;
document.getElementById('schema-modal-name').readOnly = true;
document.getElementById('schema-modal-kind').value = kind;
document.getElementById('schema-template-select').value = '';
document.getElementById('schema-modal-content').value = found.content || '';
document.getElementById('schema-modal-footer-msg').textContent = `Editing registered ${kind} schema`;
onSchemaContentInput();
document.getElementById('schema-editor-modal').style.display = 'flex';
}
function closeSchemaModal() {
document.getElementById('schema-editor-modal').style.display = 'none';
}
function onSchemaModalKindChange() {
const kind = document.getElementById('schema-modal-kind').value;
const editorLabel = document.getElementById('schema-editor-label');
if (kind === 'protobuf') {
editorLabel.textContent = 'Protobuf 3 Definition (.proto)';
loadSchemaTemplate('proto_sensor');
} else if (kind === 'custom') {
editorLabel.textContent = 'Custom Codec Descriptor (JSON / Config)';
loadSchemaTemplate('custom_modbus');
} else if (kind === 'json') {
editorLabel.textContent = 'JSON Schema Specification (Draft-07)';
loadSchemaTemplate('json_metric');
} else if (kind === 'delimited') {
editorLabel.textContent = 'Delimited / CSV Field Specification';
loadSchemaTemplate('delimited_csv');
}
}
function loadSchemaTemplate(tplKey) {
if (!tplKey || !SCHEMA_TEMPLATES[tplKey]) return;
document.getElementById('schema-modal-content').value = SCHEMA_TEMPLATES[tplKey];
onSchemaContentInput();
}
function formatSchemaContent() {
const kind = document.getElementById('schema-modal-kind').value;
const content = document.getElementById('schema-modal-content').value;
if (kind === 'json' || kind === 'custom' || kind === 'delimited') {
try {
const parsed = JSON.parse(content);
document.getElementById('schema-modal-content').value = JSON.stringify(parsed, null, 2);
showToast('JSON formatted', 'info');
} catch (_) {
showToast('Cannot format: invalid JSON', 'warning');
}
} else {
const formatted = content.split('\n').map(l => l.trimEnd()).join('\n');
document.getElementById('schema-modal-content').value = formatted;
showToast('Protobuf definition cleaned', 'info');
}
}
function handleSchemaFileUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target.result;
document.getElementById('schema-modal-content').value = text;
const nameInput = document.getElementById('schema-modal-name');
if (!nameInput.value) {
nameInput.value = file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9_]/g, '_');
}
if (file.name.endsWith('.proto')) {
document.getElementById('schema-modal-kind').value = 'protobuf';
} else if (file.name.endsWith('.json')) {
document.getElementById('schema-modal-kind').value = 'json';
}
onSchemaContentInput();
showToast(`Loaded file '${file.name}'`, 'info');
};
reader.readAsText(file);
}
function onSchemaContentInput() {
const kind = document.getElementById('schema-modal-kind').value;
const content = document.getElementById('schema-modal-content').value;
if (kind === 'protobuf') {
const res = parseProtoDefinition(content);
document.getElementById('schema-ast-syntax').textContent = res.syntax || 'proto3';
document.getElementById('schema-ast-package').textContent = res.package || '(none)';
document.getElementById('schema-ast-msg-count').textContent = res.messages.length;
document.getElementById('schema-ast-enum-count').textContent = res.enums.length;
const msgBox = document.getElementById('schema-ast-messages-container');
if (res.messages.length === 0) {
msgBox.innerHTML = '<div style="font-size:11px; color:var(--text-muted);">No messages detected.</div>';
} else {
msgBox.innerHTML = res.messages.map(m => `
<div style="background:var(--bg-subtle); padding:6px 10px; border-radius:6px; border:1px solid var(--border);">
<div style="font-weight:700; font-size:12px; font-family:var(--font-mono); color:var(--text-primary); margin-bottom:4px;">
message ${escapeHtml(m.name)}
</div>
<div style="font-size:11px; color:var(--text-secondary); font-family:var(--font-mono);">
${m.fields.map(f => `${escapeHtml(f.type)} ${escapeHtml(f.name)} = ${f.tag};`).join('<br/>')}
</div>
</div>
`).join('');
}
const enumBox = document.getElementById('schema-ast-enums-container');
if (res.enums.length === 0) {
enumBox.innerHTML = '<div style="font-size:11px; color:var(--text-muted);">No enums defined.</div>';
} else {
enumBox.innerHTML = res.enums.map(e => `
<span class="badge badge-slate" style="font-family:var(--font-mono); font-size:11px;">enum ${escapeHtml(e.name)}</span>
`).join(' ');
}
const statusBox = document.getElementById('schema-ast-status-box');
const statusText = document.getElementById('schema-ast-status-text');
if (res.valid) {
statusBox.style.background = 'rgba(16, 185, 129, 0.1)';
statusBox.style.borderColor = '#10b981';
statusText.style.color = '#10b981';
statusText.textContent = `🟢 Valid ${res.syntax} • ${res.messages.length} Message${res.messages.length !== 1 ? 's' : ''} extracted`;
} else {
statusBox.style.background = 'rgba(239, 68, 68, 0.1)';
statusBox.style.borderColor = '#ef4444';
statusText.style.color = '#ef4444';
statusText.textContent = `🔴 Syntax Warning: ${res.error}`;
}
} else {
document.getElementById('schema-ast-syntax').textContent = kind.toUpperCase();
document.getElementById('schema-ast-package').textContent = 'n/a';
const msgBox = document.getElementById('schema-ast-messages-container');
const enumBox = document.getElementById('schema-ast-enums-container');
const statusBox = document.getElementById('schema-ast-status-box');
const statusText = document.getElementById('schema-ast-status-text');
try {
const parsed = JSON.parse(content);
const topKeys = Object.keys(parsed);
document.getElementById('schema-ast-msg-count').textContent = topKeys.length;
document.getElementById('schema-ast-enum-count').textContent = 0;
msgBox.innerHTML = `<div style="font-size:11px; font-family:var(--font-mono); color:var(--text-secondary);">Top keys: ${topKeys.join(', ')}</div>`;
enumBox.innerHTML = '<div style="font-size:11px; color:var(--text-muted);">(JSON object)</div>';
statusBox.style.background = 'rgba(16, 185, 129, 0.1)';
statusBox.style.borderColor = '#10b981';
statusText.style.color = '#10b981';
statusText.textContent = '🟢 Valid JSON document';
} catch (e) {
document.getElementById('schema-ast-msg-count').textContent = 0;
document.getElementById('schema-ast-enum-count').textContent = 0;
statusBox.style.background = 'rgba(239, 68, 68, 0.1)';
statusBox.style.borderColor = '#ef4444';
statusText.style.color = '#ef4444';
statusText.textContent = `🔴 Invalid JSON: ${e.message}`;
}
}
}
function validateSchemaModalSyntax() {
onSchemaContentInput();
const statusText = document.getElementById('schema-ast-status-text')?.textContent || '';
showToast(statusText, statusText.includes('Valid') ? 'success' : 'warning');
}
async function submitSchemaModal() {
const name = document.getElementById('schema-modal-name').value.trim();
const kind = document.getElementById('schema-modal-kind').value;
const content = document.getElementById('schema-modal-content').value;
if (!name) {
showToast('Schema Name / ID is required', 'warning');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
showToast('Schema Name may only contain alphanumeric characters, hyphens, and underscores', 'warning');
return;
}
if (!content.trim()) {
showToast('Schema content cannot be empty', 'warning');
return;
}
if (kind === 'protobuf') {
const check = parseProtoDefinition(content);
if (!check.valid) {
if (!confirm(`Warning: Protobuf syntax check flagged: "${check.error}". Do you want to register it anyway?`)) {
return;
}
}
}
try {
if (kind === 'protobuf' || kind === 'custom') {
const method = schemasState.isEditing ? 'PUT' : 'POST';
const url = schemasState.isEditing
? `/schemas/${kind}/${encodeURIComponent(name)}`
: `/schemas/${kind}`;
const bodyPayload = schemasState.isEditing
? { content }
: { name, content };
const res = await apiFetch(url, {
method,
body: bodyPayload
});
if (res.ok) {
showToast(`Schema '${name}' (${kind}) saved successfully!`, 'success');
closeSchemaModal();
await fetchSchemas();
} else {
if (!schemasState.isEditing && res.status === 400 && String(res.body?.message || '').includes('already registered')) {
const putRes = await apiFetch(`/schemas/${kind}/${encodeURIComponent(name)}`, {
method: 'PUT',
body: { content }
});
if (putRes.ok) {
showToast(`Schema '${name}' updated successfully!`, 'success');
closeSchemaModal();
await fetchSchemas();
return;
}
}
const msg = res.body?.message || `Failed to save schema (${res.status})`;
showToast(`Error: ${msg}`, 'error');
}
} else {
const parsedProto = null;
upsertLocalExtendedSchema({
name,
kind,
content,
file: null,
parsed: parsedProto,
messages: [kind.toUpperCase()]
});
showToast(`Schema '${name}' (${kind}) registered locally!`, 'success');
closeSchemaModal();
await fetchSchemas();
}
} catch (err) {
showToast(`Save error: ${err.message}`, 'error');
}
}
async function deleteSchemaWithRefGuard(kind, name) {
const refs = getSchemaReferences(name);
const totalRefs = refs.streams.length + refs.tables.length + refs.rules.length;
if (totalRefs > 0) {
const streamList = refs.streams.length ? `Streams: ${refs.streams.join(', ')}` : '';
const ruleList = refs.rules.length ? `Rules: ${refs.rules.join(', ')}` : '';
const details = [streamList, ruleList].filter(Boolean).join('; ');
if (!confirm(`Warning: Schema '${name}' is actively referenced by: ${details}.\n\nDropping it may cause running pipelines to fail deserialization. Are you sure you want to proceed?`)) {
return;
}
} else {
if (!confirm(`Are you sure you want to drop schema '${name}' (${kind})? This cannot be undone.`)) {
return;
}
}
try {
if (kind === 'protobuf' || kind === 'custom') {
const res = await apiFetch(`/schemas/${kind}/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast(`Schema '${name}' dropped`, 'info');
} else {
showToast(`Failed to drop schema (${res.status})`, 'error');
}
} else {
removeLocalExtendedSchema(kind, name);
showToast(`Schema '${name}' dropped`, 'info');
}
await fetchSchemas();
} catch (err) {
showToast(`Delete error: ${err.message}`, 'error');
}
}
function openSchemaInspectorModal(kind, name) {
const found = schemasState.items.find(s => s.name === name && s.kind === kind);
if (!found) {
showToast(`Schema '${name}' not found`, 'warning');
return;
}
schemasState.activeInspectSchema = found;
document.getElementById('schema-inspect-title').textContent = `Schema Inspector: ${name}`;
document.getElementById('schema-inspect-subtitle').textContent = `Protocol: ${kind.toUpperCase()} • Length: ${(found.content || '').length} characters`;
const kindBadge = document.getElementById('schema-inspect-kind-badge');
kindBadge.textContent = kind.toUpperCase();
kindBadge.className = 'badge ' + (kind === 'protobuf' ? 'badge-indigo' : (kind === 'custom' ? 'badge-cyan' : 'badge-amber'));
const refs = getSchemaReferences(name);
const totalRefs = refs.streams.length + refs.tables.length + refs.rules.length;
const refBadge = document.getElementById('schema-inspect-ref-badge');
if (totalRefs > 0) {
refBadge.textContent = `✓ ${totalRefs} Active Pipeline Reference${totalRefs > 1 ? 's' : ''}`;
refBadge.className = 'badge badge-emerald';
refBadge.title = `Streams: ${refs.streams.join(', ') || 'None'}; Rules: ${refs.rules.join(', ') || 'None'}`;
} else {
refBadge.textContent = '0 Pipeline References (Unused)';
refBadge.className = 'badge badge-slate';
refBadge.title = 'No active streams or rules reference this schema';
}
const typesList = document.getElementById('schema-inspect-types-list');
if (Array.isArray(found.messages) && found.messages.length > 0) {
typesList.innerHTML = found.messages.map(m => `<span class="badge badge-slate" style="font-family:var(--font-mono); font-size:12px; font-weight:600;">${escapeHtml(m)}</span>`).join(' ');
} else {
typesList.innerHTML = '<span style="font-size:11px; color:var(--text-muted);">(no extracted messages)</span>';
}
document.getElementById('schema-inspect-code').textContent = found.content || '(empty)';
document.getElementById('schema-inspector-modal').style.display = 'flex';
}
function closeSchemaInspectorModal() {
document.getElementById('schema-inspector-modal').style.display = 'none';
}
function copySchemaInspectContent() {
const content = schemasState.activeInspectSchema?.content || '';
navigator.clipboard.writeText(content).then(() => {
showToast('Schema content copied to clipboard', 'info');
}).catch(err => {
showToast(`Copy failed: ${err.message}`, 'error');
});
}
function editFromSchemaInspector() {
const s = schemasState.activeInspectSchema;
if (!s) return;
closeSchemaInspectorModal();
openEditSchemaModal(s.kind, s.name);
}
const pluginsState = {
subTab: 'plugins',
plugins: [],
jsUdfs: [],
services: [],
serviceFunctions: [],
allFunctions: [],
uploads: [],
activeInspectPlugin: null,
activeEditJsUdfId: null,
uploadFileData: null,
uploadFileName: '',
importFileContent: null,
importFileName: ''
};
function switchExtSubTab(subTab) {
pluginsState.subTab = subTab;
['plugins', 'udfs', 'services', 'uploads', 'backup'].forEach(t => {
const btn = document.getElementById(`ext-subtab-btn-${t}`);
const panel = document.getElementById(`ext-panel-${t}`);
if (btn) btn.classList.toggle('active', t === subTab);
if (panel) panel.style.display = (t === subTab) ? 'flex' : 'none';
});
if (subTab === 'plugins') renderPluginsTable();
if (subTab === 'udfs') { renderJsUdfsTable(); renderSqlFunctionsPalette(); }
if (subTab === 'services') renderServicesTable();
if (subTab === 'uploads') renderUploadsTable();
}
async function fetchPluginsHubData() {
try {
const [
sourcesRes, sinksRes, funcsRes, wasmRes,
udfsRes, servicesRes, svcFuncsRes,
uploadsRes, metaFuncsRes
] = await Promise.allSettled([
apiFetch('/plugins/sources'),
apiFetch('/plugins/sinks'),
apiFetch('/plugins/functions'),
apiFetch('/plugins/wasm'),
apiFetch('/udf/javascript'),
apiFetch('/services'),
apiFetch('/services/functions'),
apiFetch('/config/uploads'),
apiFetch('/metadata/functions')
]);
const combined = [];
if (sourcesRes.status === 'fulfilled' && Array.isArray(sourcesRes.value.body)) {
sourcesRes.value.body.forEach(p => combined.push({ name: typeof p === 'string' ? p : (p.name || p.id), type: 'source', kind: 'Source Connector' }));
}
if (sinksRes.status === 'fulfilled' && Array.isArray(sinksRes.value.body)) {
sinksRes.value.body.forEach(p => combined.push({ name: typeof p === 'string' ? p : (p.name || p.id), type: 'sink', kind: 'Sink Connector' }));
}
if (funcsRes.status === 'fulfilled' && Array.isArray(funcsRes.value.body)) {
funcsRes.value.body.forEach(p => combined.push({ name: typeof p === 'string' ? p : (p.name || p.id), type: 'function', kind: 'Function Plugin' }));
}
if (wasmRes.status === 'fulfilled' && Array.isArray(wasmRes.value.body)) {
wasmRes.value.body.forEach(p => combined.push({ name: typeof p === 'string' ? p : (p.name || p.id), type: 'wasm', kind: 'WebAssembly (Wasm)' }));
}
pluginsState.plugins = combined;
document.getElementById('ext-count-plugins').textContent = combined.length;
document.getElementById('plugins-table-count').textContent = combined.length;
if (udfsRes.status === 'fulfilled' && Array.isArray(udfsRes.value.body)) {
const udfIds = udfsRes.value.body;
const details = await Promise.all(udfIds.map(id => apiFetch(`/udf/javascript/${encodeURIComponent(id)}`).then(r => r.ok ? r.body : { id })));
pluginsState.jsUdfs = details;
} else {
pluginsState.jsUdfs = [];
}
document.getElementById('ext-count-udfs').textContent = pluginsState.jsUdfs.length;
document.getElementById('udfs-table-count').textContent = pluginsState.jsUdfs.length;
if (metaFuncsRes.status === 'fulfilled' && Array.isArray(metaFuncsRes.value.body)) {
pluginsState.allFunctions = metaFuncsRes.value.body;
document.getElementById('sql-funcs-count').textContent = metaFuncsRes.value.body.length;
}
if (servicesRes.status === 'fulfilled' && Array.isArray(servicesRes.value.body)) {
const svcs = servicesRes.value.body;
const details = await Promise.all(svcs.map(name => apiFetch(`/services/${encodeURIComponent(name)}`).then(r => r.ok ? Object.assign({ name }, r.body) : { name })));
pluginsState.services = details;
} else {
pluginsState.services = [];
}
document.getElementById('ext-count-services').textContent = pluginsState.services.length;
document.getElementById('services-table-count').textContent = pluginsState.services.length;
if (svcFuncsRes.status === 'fulfilled' && Array.isArray(svcFuncsRes.value.body)) {
pluginsState.serviceFunctions = svcFuncsRes.value.body;
} else {
pluginsState.serviceFunctions = [];
}
document.getElementById('service-funcs-count').textContent = pluginsState.serviceFunctions.length;
if (uploadsRes.status === 'fulfilled' && Array.isArray(uploadsRes.value.body)) {
pluginsState.uploads = uploadsRes.value.body;
} else {
pluginsState.uploads = [];
}
document.getElementById('ext-count-uploads').textContent = pluginsState.uploads.length;
document.getElementById('uploads-table-count').textContent = pluginsState.uploads.length;
switchExtSubTab(pluginsState.subTab);
} catch (err) {
console.error('Failed to fetch extensions hub data:', err);
}
}
function renderPluginsTable() {
const tbody = document.getElementById('plugins-tbody');
const search = (document.getElementById('plugins-search-input').value || '').toLowerCase().trim();
const typeFilter = document.getElementById('plugins-type-filter').value;
const filtered = pluginsState.plugins.filter(p => {
if (typeFilter !== 'all' && p.type !== typeFilter) return false;
if (search && !p.name.toLowerCase().includes(search) && !p.kind.toLowerCase().includes(search)) return false;
return true;
});
document.getElementById('plugins-table-count').textContent = filtered.length;
if (filtered.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="empty-state">
<div style="font-size: 13px; font-weight: 600; margin-bottom: 4px;">No plugins installed</div>
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">Extend rekuiper with native shared libraries (.so), WebAssembly (.wasm) modules, or portable gRPC connectors.</div>
<button class="btn btn-sm btn-primary" onclick="openPluginInstallModal()">+ Register First Plugin 🔌</button>
</td>
</tr>`;
return;
}
tbody.innerHTML = filtered.map(p => {
let badgeClass = 'badge-slate';
let icon = '🔌';
if (p.type === 'wasm') { badgeClass = 'badge-cyan'; icon = '🦀'; }
else if (p.type === 'source') { badgeClass = 'badge-emerald'; icon = '📥'; }
else if (p.type === 'sink') { badgeClass = 'badge-indigo'; icon = '📤'; }
else if (p.type === 'function') { badgeClass = 'badge-purple'; icon = '⚡'; }
return `
<tr>
<td>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">${icon}</span>
<span style="font-family: var(--font-mono); font-weight: 600; font-size: 13px;">${escapeHtml(p.name)}</span>
</div>
</td>
<td><span class="badge ${badgeClass}">${escapeHtml(p.kind)}</span></td>
<td><span style="font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary);">${p.type === 'wasm' ? 'Wasm Bytecode' : 'Dynamic Library (.so)'}</span></td>
<td><span class="badge badge-emerald">Active / Loaded</span></td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" onclick="openPluginInspectModal('${escapeHtml(p.name)}', '${p.type}')">Inspect 🔍</button>
<button class="btn btn-sm" style="color: var(--rose-text);" onclick="deletePlugin('${p.type}', '${escapeHtml(p.name)}')">Uninstall 🗑</button>
</div>
</td>
</tr>`;
}).join('');
}
function openPluginInstallModal() {
document.getElementById('plugin-modal-name').value = '';
document.getElementById('plugin-modal-file').value = '';
document.getElementById('plugin-modal-symbols').value = '';
document.getElementById('plugin-install-modal').style.display = 'flex';
}
function closePluginInstallModal() {
document.getElementById('plugin-install-modal').style.display = 'none';
}
async function submitPluginInstall() {
const type = document.getElementById('plugin-modal-type').value;
const name = document.getElementById('plugin-modal-name').value.trim();
const file = document.getElementById('plugin-modal-file').value.trim();
const symbols = document.getElementById('plugin-modal-symbols').value.trim();
if (!name) {
showToast('Please specify a plugin name', 'warning');
return;
}
if (!file) {
showToast('Please specify a file path or URL', 'warning');
return;
}
const payload = { name, file };
if (symbols) {
payload.functions = symbols.split(',').map(s => s.trim()).filter(Boolean);
}
const endpoint = type === 'wasm' ? '/plugins/wasm' : `/plugins/${type}s`;
const res = await apiFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload
});
if (res.ok) {
showToast(`Plugin '${name}' registered successfully`, 'success');
closePluginInstallModal();
await fetchPluginsHubData();
} else {
const msg = typeof res.body === 'string' ? res.body : (res.body?.message || `Failed to register plugin (${res.status})`);
showToast(msg, 'error');
}
}
async function deletePlugin(type, name) {
if (!confirm(`Are you sure you want to uninstall plugin '${name}'?`)) return;
const endpoint = type === 'wasm' ? `/plugins/wasm/${encodeURIComponent(name)}` : `/plugins/${type}s/${encodeURIComponent(name)}`;
const res = await apiFetch(endpoint, { method: 'DELETE' });
if (res.ok) {
showToast(`Plugin '${name}' uninstalled`, 'info');
await fetchPluginsHubData();
} else {
showToast(`Uninstall failed (${res.status})`, 'error');
}
}
function openPluginInspectModal(name, type) {
document.getElementById('plugin-inspect-title').textContent = `Plugin: ${name}`;
document.getElementById('plugin-inspect-subtitle').textContent = `Type: ${type.toUpperCase()}`;
document.getElementById('plugin-inspect-type-badge').textContent = type.toUpperCase();
document.getElementById('plugin-inspect-symbols-list').innerHTML = `<span class="badge badge-slate">${escapeHtml(name)}</span>`;
document.getElementById('plugin-inspect-raw').textContent = JSON.stringify({ name, type, status: 'Loaded in Memory', engine: 'Native Rust Host' }, null, 2);
document.getElementById('plugin-inspect-modal').style.display = 'flex';
}
function closePluginInspectModal() {
document.getElementById('plugin-inspect-modal').style.display = 'none';
}
function renderJsUdfsTable() {
const tbody = document.getElementById('udfs-tbody');
const list = pluginsState.jsUdfs;
if (!list || list.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="empty-state">
<div style="font-size: 13px; font-weight: 600; margin-bottom: 4px;">No JavaScript functions created yet</div>
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">Author custom row-by-row or window aggregate functions directly in JavaScript with live preflight testing.</div>
<button class="btn btn-sm btn-primary" onclick="openJsUdfModal()">+ Create First JS Function ⚡</button>
</td>
</tr>`;
return;
}
tbody.innerHTML = list.map(u => `
<tr>
<td>
<span style="font-family: var(--font-mono); font-weight: 600; font-size: 13px; color: #8b5cf6;">${escapeHtml(u.id)}</span>
</td>
<td>
<span class="badge ${u.is_agg ? 'badge-amber' : 'badge-emerald'}">${u.is_agg ? 'Aggregate UDAF' : 'Scalar UDF'}</span>
</td>
<td style="font-size: 12px; color: var(--text-secondary);">${escapeHtml(u.description || '(no description)')}</td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" onclick="openJsUdfModal('${escapeHtml(u.id)}')">Edit & Sandbox ✏</button>
<button class="btn btn-sm" style="color: var(--rose-text);" onclick="deleteJsUdf('${escapeHtml(u.id)}')">Delete 🗑</button>
</div>
</td>
</tr>`).join('');
}
function renderSqlFunctionsPalette() {
const grid = document.getElementById('sql-funcs-grid');
const search = (document.getElementById('sql-funcs-search').value || '').toLowerCase().trim();
const cat = document.getElementById('sql-funcs-category').value;
const filtered = pluginsState.allFunctions.filter(f => {
if (cat !== 'all' && (f.category || '').toLowerCase() !== cat) return false;
if (search && !f.name.toLowerCase().includes(search) && !(f.description || '').toLowerCase().includes(search)) return false;
return true;
});
if (filtered.length === 0) {
grid.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); padding: 12px;">No matching functions found.</div>';
return;
}
grid.innerHTML = filtered.map(f => {
const example = f.example || `${f.name}()`;
return `
<div class="func-card">
<div style="display: flex; align-items: center; justify-content: space-between;">
<span class="func-card-sig">${escapeHtml(f.name)}(${escapeHtml(f.arity ? (f.arity === '1' ? 'arg' : `${f.arity} args`) : '...')})</span>
<span class="badge badge-slate" style="font-size: 10px; text-transform: uppercase;">${escapeHtml(f.category || 'math')}</span>
</div>
<div style="font-size: 11px; color: var(--text-secondary); line-height: 1.3;">${escapeHtml(f.description || '')}</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 4px; border-top: 1px dashed var(--border);">
<code style="font-size: 10px; color: var(--text-muted);">${escapeHtml(example)}</code>
<button type="button" class="btn btn-xs" onclick="copySqlExpression('${escapeHtml(example)}')" title="Copy example snippet">Copy 📋</button>
</div>
</div>`;
}).join('');
}
function copySqlExpression(expr) {
navigator.clipboard.writeText(expr).then(() => {
showToast(`Copied '${expr}' to clipboard`, 'info');
}).catch(e => {
showToast(`Copy failed: ${e.message}`, 'error');
});
}
const JS_TEMPLATES = {
scalar_temp: {
id: 'celsius_to_fahrenheit',
desc: 'Converts temperature from Celsius to Fahrenheit',
agg: 'false',
code: `// Scalar function taking 1 argument (temperature in Celsius)\nfunction celsius_to_fahrenheit(c) {\n if (c === null || c === undefined) return null;\n return Number(((c * 9 / 5) + 32).toFixed(2));\n}`,
args: '[25.0]'
},
scalar_norm: {
id: 'normalize_sensor',
desc: 'Normalizes raw sensor value between 0.0 and 1.0',
agg: 'false',
code: `// Normalizes raw analog reading (0-4095) to engineering units (0.0-100.0%)\nfunction normalize_sensor(raw, min_val, max_val) {\n if (raw === null || raw === undefined) return 0;\n const min = min_val || 0;\n const max = max_val || 4095;\n const scaled = ((raw - min) / (max - min)) * 100.0;\n return Math.max(0.0, Math.min(100.0, scaled));\n}`,
args: '[2048, 0, 4095]'
},
agg_median: {
id: 'moving_median',
desc: 'Calculates the statistical median of a sliding window of numbers',
agg: 'true',
code: `// Window aggregate function receiving an array of values\nfunction moving_median(arr) {\n if (!Array.isArray(arr) || arr.length === 0) return null;\n const sorted = arr.slice().sort((a, b) => a - b);\n const mid = Math.floor(sorted.length / 2);\n return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;\n}`,
args: '[[12.5, 18.2, 14.0, 16.8, 19.1]]'
},
agg_range: {
id: 'peak_to_peak',
desc: 'Calculates peak-to-peak difference (max - min) in window',
agg: 'true',
code: `// Computes peak-to-peak delta across temporal window\nfunction peak_to_peak(values) {\n if (!Array.isArray(values) || values.length === 0) return 0;\n let min = values[0];\n let max = values[0];\n for (let i = 1; i < values.length; i++) {\n if (values[i] < min) min = values[i];\n if (values[i] > max) max = values[i];\n }\n return Number((max - min).toFixed(3));\n}`,
args: '[[4.2, 8.5, 3.1, 9.4, 6.7]]'
}
};
function openJsUdfModal(id) {
pluginsState.activeEditJsUdfId = id || null;
const titleEl = document.getElementById('js-udf-modal-title');
const idEl = document.getElementById('js-udf-modal-id');
const aggEl = document.getElementById('js-udf-modal-agg');
const descEl = document.getElementById('js-udf-modal-desc');
const codeEl = document.getElementById('js-udf-modal-code');
const argsEl = document.getElementById('js-udf-sandbox-args');
const resEl = document.getElementById('js-udf-sandbox-result');
resEl.textContent = 'Click "Run Test" to evaluate.';
resEl.style.color = 'var(--text-secondary)';
if (id) {
const found = pluginsState.jsUdfs.find(u => u.id === id);
titleEl.textContent = `Edit JavaScript Function: ${id}`;
idEl.value = id;
idEl.disabled = true;
aggEl.value = String(found?.is_agg === true);
descEl.value = found?.description || '';
codeEl.value = found?.script || `function ${id}(x) {\n return x;\n}`;
argsEl.value = found?.is_agg ? '[[1, 2, 3, 4]]' : '[10]';
} else {
titleEl.textContent = 'New JavaScript User-Defined Function (UDF)';
idEl.value = '';
idEl.disabled = false;
aggEl.value = 'false';
descEl.value = '';
loadJsUdfTemplate('scalar_temp');
}
document.getElementById('js-udf-modal').style.display = 'flex';
}
function closeJsUdfModal() {
document.getElementById('js-udf-modal').style.display = 'none';
}
function loadJsUdfTemplate(key) {
if (!key || !JS_TEMPLATES[key]) return;
const t = JS_TEMPLATES[key];
if (!pluginsState.activeEditJsUdfId) {
document.getElementById('js-udf-modal-id').value = t.id;
document.getElementById('js-udf-modal-desc').value = t.desc;
document.getElementById('js-udf-modal-agg').value = t.agg;
}
document.getElementById('js-udf-modal-code').value = t.code;
document.getElementById('js-udf-sandbox-args').value = t.args;
runJsUdfTestSandbox();
}
function handleJsUdfKindChange() {
const isAgg = document.getElementById('js-udf-modal-agg').value === 'true';
if (!pluginsState.activeEditJsUdfId) {
loadJsUdfTemplate(isAgg ? 'agg_median' : 'scalar_temp');
}
}
function runJsUdfTestSandbox() {
const code = document.getElementById('js-udf-modal-code').value;
const funcId = document.getElementById('js-udf-modal-id').value.trim() || 'func';
const rawArgs = document.getElementById('js-udf-sandbox-args').value.trim();
const resEl = document.getElementById('js-udf-sandbox-result');
let parsedArgs = [];
try {
parsedArgs = JSON.parse(rawArgs);
if (!Array.isArray(parsedArgs)) {
throw new Error('Arguments must be specified as a JSON array, e.g. [25] or [[1, 2, 3]]');
}
} catch (err) {
resEl.style.color = '#ef4444';
resEl.textContent = `Arguments format error:\n${err.message}`;
return;
}
try {
const t0 = performance.now();
const runner = new Function('args', `
${code}
if (typeof ${funcId} !== 'function') {
throw new Error("Function '" + ${JSON.stringify(funcId)} + "' is not declared in the script");
}
return ${funcId}(...args);
`);
const output = runner(parsedArgs);
const t1 = performance.now();
const duration = (t1 - t0).toFixed(2);
resEl.style.color = '#10b981';
resEl.textContent = `✓ Sandbox Passed (${duration} ms):\nOutput: ${JSON.stringify(output, null, 2)}\nReturn Type: ${typeof output}`;
} catch (err) {
resEl.style.color = '#ef4444';
resEl.textContent = `✗ Execution Failed:\n${err.message}`;
}
}
async function submitJsUdfModal() {
const id = document.getElementById('js-udf-modal-id').value.trim();
const is_agg = document.getElementById('js-udf-modal-agg').value === 'true';
const description = document.getElementById('js-udf-modal-desc').value.trim();
const script = document.getElementById('js-udf-modal-code').value;
if (!id) {
showToast('Function name identifier is required', 'warning');
return;
}
if (!script.trim()) {
showToast('JavaScript implementation cannot be empty', 'warning');
return;
}
const payload = { id, description, script, is_agg };
const isUpdate = pluginsState.activeEditJsUdfId !== null;
const endpoint = isUpdate ? `/udf/javascript/${encodeURIComponent(id)}` : '/udf/javascript';
const method = isUpdate ? 'PUT' : 'POST';
const res = await apiFetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: payload
});
if (res.ok) {
showToast(`JavaScript UDF '${id}' saved and compiled`, 'success');
closeJsUdfModal();
await fetchPluginsHubData();
} else {
const msg = typeof res.body === 'string' ? res.body : (res.body?.message || `Failed to save function (${res.status})`);
showToast(msg, 'error');
}
}
async function deleteJsUdf(id) {
if (!confirm(`Are you sure you want to delete JavaScript function '${id}'? This will unregister it from SQL evaluation.`)) return;
const res = await apiFetch(`/udf/javascript/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (res.ok) {
showToast(`Function '${id}' deleted`, 'info');
await fetchPluginsHubData();
} else {
showToast(`Delete failed (${res.status})`, 'error');
}
}
function renderServicesTable() {
const sBody = document.getElementById('services-tbody');
const sfBody = document.getElementById('service-funcs-tbody');
const services = pluginsState.services;
const serviceFuncs = pluginsState.serviceFunctions;
if (services.length === 0) {
sBody.innerHTML = `
<tr>
<td colspan="4" class="empty-state">
<div style="font-size: 13px; font-weight: 600; margin-bottom: 4px;">No external RPC services registered</div>
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">Connect external microservices (REST/gRPC) and expose their endpoints as callable SQL functions.</div>
<button class="btn btn-sm btn-primary" onclick="openServiceRegisterModal()">+ Register First Service 🌐</button>
</td>
</tr>`;
} else {
sBody.innerHTML = services.map(s => {
const ifaces = s.interfaces ? Object.keys(s.interfaces).join(', ') : '(none)';
const addr = s.about?.file || Object.values(s.interfaces || {})[0]?.addr || 'http://127.0.0.1';
return `
<tr>
<td><span style="font-family: var(--font-mono); font-weight: 600; font-size: 13px;">${escapeHtml(s.name)}</span></td>
<td><code style="font-size: 12px; color: var(--text-secondary);">${escapeHtml(addr)}</code></td>
<td><span class="badge badge-indigo">${escapeHtml(ifaces)}</span></td>
<td style="text-align: right;">
<button class="btn btn-sm" style="color: var(--rose-text);" onclick="deleteService('${escapeHtml(s.name)}')">Delete 🗑</button>
</td>
</tr>`;
}).join('');
}
if (serviceFuncs.length === 0) {
sfBody.innerHTML = `<tr><td colspan="4" class="empty-state">No methods registered across external services.</td></tr>`;
} else {
sfBody.innerHTML = serviceFuncs.map(f => `
<tr>
<td><span style="font-family: var(--font-mono); font-weight: 700; color: #8b5cf6;">${escapeHtml(f.func_name || f.method_name)}()</span></td>
<td><span class="badge badge-slate">${escapeHtml(f.service_name || '')}</span></td>
<td><code>${escapeHtml(f.interface_name || '')}</code></td>
<td><code style="font-size: 11px; color: var(--text-muted);">${escapeHtml(f.addr || '')} → ${escapeHtml(f.method_name || '')}</code></td>
</tr>`).join('');
}
}
function openServiceRegisterModal() {
document.getElementById('service-modal-name').value = '';
document.getElementById('service-modal-addr').value = 'http://127.0.0.1:8080';
document.getElementById('service-modal-iface').value = 'api';
document.getElementById('service-modal-methods').value = 'predict, classify';
document.getElementById('service-register-modal').style.display = 'flex';
}
function closeServiceRegisterModal() {
document.getElementById('service-register-modal').style.display = 'none';
}
async function submitServiceRegister() {
const name = document.getElementById('service-modal-name').value.trim();
const addr = document.getElementById('service-modal-addr').value.trim();
const iface = document.getElementById('service-modal-iface').value.trim();
const methodsRaw = document.getElementById('service-modal-methods').value.trim();
if (!name) { showToast('Service name is required', 'warning'); return; }
if (!addr) { showToast('Service endpoint address is required', 'warning'); return; }
if (!iface) { showToast('Interface identifier is required', 'warning'); return; }
const methods = methodsRaw.split(',').map(m => m.trim()).filter(Boolean);
const payload = {
name,
About: { file: addr },
Interfaces: {
[iface]: {
addr,
methods
}
}
};
const res = await apiFetch('/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload
});
if (res.ok) {
showToast(`Service '${name}' registered with ${methods.length} callable methods`, 'success');
closeServiceRegisterModal();
await fetchPluginsHubData();
} else {
const msg = typeof res.body === 'string' ? res.body : (res.body?.message || `Failed to register service (${res.status})`);
showToast(msg, 'error');
}
}
async function deleteService(name) {
if (!confirm(`Are you sure you want to unregister service '${name}'?`)) return;
const res = await apiFetch(`/services/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast(`Service '${name}' deleted`, 'info');
await fetchPluginsHubData();
} else {
showToast(`Delete failed (${res.status})`, 'error');
}
}
function renderUploadsTable() {
const tbody = document.getElementById('uploads-tbody');
const files = pluginsState.uploads;
if (files.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="empty-state">
<div style="font-size: 13px; font-weight: 600; margin-bottom: 4px;">No configuration files stored</div>
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">Upload TLS certificates, Proto3 files, ML models, or custom binary codecs.</div>
<button class="btn btn-sm btn-primary" onclick="openFileUploadModal()">Upload First Artifact 📁</button>
</td>
</tr>`;
return;
}
tbody.innerHTML = files.map(name => {
const edgePath = `data/uploads/${name}`;
let hint = 'Referenced in configuration keys & rules';
if (name.endsWith('.pem') || name.endsWith('.crt') || name.endsWith('.key')) hint = 'TLS Certificate / Key';
else if (name.endsWith('.proto')) hint = 'Protobuf v3 Contract';
else if (name.endsWith('.wasm')) hint = 'Wasm Executable Codec';
else if (name.endsWith('.onnx')) hint = 'Neural Inference Model';
return `
<tr>
<td>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">📄</span>
<span style="font-family: var(--font-mono); font-weight: 600;">${escapeHtml(name)}</span>
</div>
</td>
<td><code style="font-size: 12px; color: var(--text-secondary);">${escapeHtml(edgePath)}</code></td>
<td><span class="badge badge-slate">${escapeHtml(hint)}</span></td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 6px;">
<button class="btn btn-sm" onclick="copyUploadFilePath('${escapeHtml(edgePath)}')">Copy Path 📋</button>
<button class="btn btn-sm" style="color: var(--rose-text);" onclick="deleteUploadFile('${escapeHtml(name)}')">Delete 🗑</button>
</div>
</td>
</tr>`;
}).join('');
}
function copyUploadFilePath(path) {
navigator.clipboard.writeText(path).then(() => {
showToast(`Copied '${path}' to clipboard`, 'info');
}).catch(e => {
showToast(`Copy failed: ${e.message}`, 'error');
});
}
function openFileUploadModal() {
pluginsState.uploadFileData = null;
pluginsState.uploadFileName = '';
document.getElementById('upload-modal-name').value = '';
document.getElementById('upload-modal-file-label').textContent = 'Click or Drag & Drop File Here';
document.getElementById('upload-modal-submit-btn').disabled = true;
document.getElementById('file-upload-modal').style.display = 'flex';
}
function closeFileUploadModal() {
document.getElementById('file-upload-modal').style.display = 'none';
}
function handleModalFileSelect(event) {
const file = event.target.files?.[0];
if (!file) return;
pluginsState.uploadFileName = file.name;
document.getElementById('upload-modal-name').value = file.name;
document.getElementById('upload-modal-file-label').textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
document.getElementById('upload-modal-submit-btn').disabled = false;
const reader = new FileReader();
reader.onload = e => {
pluginsState.uploadFileData = e.target.result;
};
reader.readAsText(file);
}
async function submitFileUpload() {
const name = document.getElementById('upload-modal-name').value.trim();
const content = pluginsState.uploadFileData || '';
if (!name) {
showToast('Filename is required', 'warning');
return;
}
const res = await apiFetch('/config/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { name, content }
});
if (res.ok) {
showToast(`Artifact '${name}' stored in data/uploads/`, 'success');
closeFileUploadModal();
await fetchPluginsHubData();
} else {
const msg = typeof res.body === 'string' ? res.body : (res.body?.message || `Upload failed (${res.status})`);
showToast(msg, 'error');
}
}
async function deleteUploadFile(name) {
if (!confirm(`Are you sure you want to delete file '${name}' from edge storage?`)) return;
const res = await apiFetch(`/config/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (res.ok) {
showToast(`File '${name}' deleted`, 'info');
await fetchPluginsHubData();
} else {
showToast(`Delete failed (${res.status})`, 'error');
}
}
function toggleExportAll(checked) {
const box = document.getElementById('export-details-box');
if (box) box.style.opacity = checked ? '1' : '0.5';
}
async function triggerDataExport(format) {
showToast(`Generating ${format.toUpperCase()} export snapshot...`, 'info');
try {
const endpoint = format === 'yaml' ? '/v2/data/export' : '/data/export';
const res = await fetch(endpoint);
if (!res.ok) throw new Error(`Export request failed (${res.status})`);
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rekuiper_snapshot_${new Date().toISOString().slice(0, 10)}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
showToast(`Snapshot exported successfully (${format.toUpperCase()})`, 'success');
} catch (err) {
showToast(`Export error: ${err.message}`, 'error');
}
}
function handleImportFileSelect(event) {
const file = event.target.files?.[0];
if (!file) return;
pluginsState.importFileName = file.name;
document.getElementById('import-file-label').textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
document.getElementById('import-submit-btn').disabled = false;
const reader = new FileReader();
reader.onload = e => {
pluginsState.importFileContent = e.target.result;
};
reader.readAsText(file);
}
async function submitDataImport() {
const content = pluginsState.importFileContent;
if (!content) {
showToast('Please select a valid JSON or YAML snapshot first', 'warning');
return;
}
const stopRules = document.getElementById('import-stop-rules').checked;
const statusBox = document.getElementById('import-status-box');
statusBox.style.display = 'block';
statusBox.style.background = 'var(--bg-subtle)';
statusBox.style.color = 'var(--text-secondary)';
statusBox.textContent = 'Applying snapshot & reconciling topology...';
try {
let payload = null;
try {
payload = JSON.parse(content);
} catch (_) {
payload = { content };
}
const endpoint = `/data/import?stop=${stopRules ? '1' : '0'}`;
const res = await apiFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload
});
if (res.ok) {
statusBox.style.background = '#ecfdf5';
statusBox.style.color = '#065f46';
statusBox.textContent = `✓ Import succeeded: Snapshot applied & reconciled.`;
showToast('Topology restored & reconciled', 'success');
await refreshAll();
await fetchPluginsHubData();
} else {
statusBox.style.background = '#fef2f2';
statusBox.style.color = '#991b1b';
const msg = typeof res.body === 'string' ? res.body : (res.body?.message || `Import failed (${res.status})`);
statusBox.textContent = `✗ Import failed: ${msg}`;
showToast(msg, 'error');
}
} catch (err) {
statusBox.style.background = '#fef2f2';
statusBox.style.color = '#991b1b';
statusBox.textContent = `✗ Parse error: ${err.message}`;
showToast(err.message, 'error');
}
}
checkQuickStartBannerPreference();
checkConsoleAuthStatus();
loadPresetPayload('/v1/metrics');
refreshAll(false);
updatePollInterval(2000);
</script>
</body>
</html>