secubox-openwrt/streamlit-apps/fabricator/app.py
CyberMind-FR 3e2101725e feat(fabricator): Add Embedder tab and sync to repo
- Add 7th tab "🪟 Embedder" for creating unified portal pages
- Embeds Streamlit apps, MetaBlogizer sites, custom URLs
- Three layouts: Grid, Tabs, Sidebar
- Auto-fetches available services from JSON endpoints
- Add streamlit-apps/fabricator/ to repo
- Update HISTORY.md with session changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:09:47 +01:00

641 lines
26 KiB
Python

#!/usr/bin/env python3
"""
SecuBox Fabricator - Widget & Component Constructor
Multi-tab Streamlit app for building SecuBox components
"""
import streamlit as st
import json
import subprocess
import os
from datetime import datetime
st.set_page_config(
page_title="SecuBox Fabricator",
page_icon="🔧",
layout="wide",
initial_sidebar_state="collapsed"
)
# Custom CSS
st.markdown("""
<style>
#MainMenu, header, footer, .stDeployButton {display: none !important;}
.block-container {padding: 0.5rem 1rem !important; max-width: 100% !important;}
.stApp { background: #0a0a12; color: #e0e0e0; }
.title { font-size: 2rem; font-weight: 700; text-align: center;
background: linear-gradient(90deg, #00d4aa, #ff00ff);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
margin-bottom: 1rem; }
.card { background: #12121a; border-radius: 12px; padding: 1rem; margin: 0.5rem 0;
border-left: 4px solid #00d4aa; }
.success { border-left-color: #00ff88 !important; }
.warning { border-left-color: #ffaa00 !important; }
.error { border-left-color: #ff4444 !important; }
.code-preview { background: #1a1a24; border-radius: 8px; padding: 1rem;
font-family: monospace; font-size: 0.8rem; overflow-x: auto; }
</style>
""", unsafe_allow_html=True)
def run_cmd(cmd, timeout=30):
"""Run shell command and return output"""
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return result.stdout.strip(), result.returncode == 0
except subprocess.TimeoutExpired:
return "Command timed out", False
except Exception as e:
return str(e), False
def ssh_cmd(cmd, timeout=30):
"""Run command on router via SSH"""
return run_cmd(f'ssh -o ConnectTimeout=5 root@192.168.255.1 "{cmd}"', timeout)
# Main title
st.markdown('<h1 class="title">🔧 SecuBox Fabricator</h1>', unsafe_allow_html=True)
# Tab navigation
tabs = st.tabs(["📊 Collectors", "🚀 Apps", "📝 Blogs", "🌐 Statics", "🔌 Services", "🧩 Widgets", "🪟 Embedder"])
# ============== TAB 1: COLLECTORS ==============
with tabs[0]:
st.subheader("Stats Collector Builder")
st.markdown("Create custom stats collection scripts")
col1, col2 = st.columns([1, 1])
with col1:
collector_name = st.text_input("Collector Name", value="my-collector", key="coll_name")
collector_type = st.selectbox("Template", ["Custom", "CrowdSec", "mitmproxy", "Firewall", "Network"])
data_source = st.text_input("Data Source", value="/var/log/myservice.log", key="coll_src")
output_path = st.text_input("Output JSON", value=f"/tmp/secubox/{collector_name}.json", key="coll_out")
cron_schedule = st.selectbox("Update Frequency", ["*/5 * * * *", "*/1 * * * *", "*/15 * * * *", "0 * * * *"])
# Template-based script generation
if collector_type == "Custom":
script_template = f'''#!/bin/sh
# {collector_name} - Custom Stats Collector
OUTPUT="{output_path}"
SRC="{data_source}"
# Parse your data source here
count=$(wc -l < "$SRC" 2>/dev/null | tr -cd '0-9')
[ -z "$count" ] && count=0
cat > "$OUTPUT" << EOF
{{
"count": $count,
"last_update": "$(date -Iseconds)"
}}
EOF
'''
elif collector_type == "CrowdSec":
script_template = f'''#!/bin/sh
# {collector_name} - CrowdSec Stats
OUTPUT="{output_path}"
bans=$(cscli decisions list -o json 2>/dev/null | jsonfilter -e "@[*]" 2>/dev/null | wc -l)
[ -z "$bans" ] && bans=0
cat > "$OUTPUT" << EOF
{{ "bans": $bans, "last_update": "$(date -Iseconds)" }}
EOF
'''
elif collector_type == "mitmproxy":
script_template = f'''#!/bin/sh
# {collector_name} - mitmproxy WAF Stats
OUTPUT="{output_path}"
threats=$(wc -l < /srv/mitmproxy/threats.log 2>/dev/null | tr -cd '0-9')
[ -z "$threats" ] && threats=0
cat > "$OUTPUT" << EOF
{{ "threats": $threats, "last_update": "$(date -Iseconds)" }}
EOF
'''
elif collector_type == "Firewall":
script_template = f'''#!/bin/sh
# {collector_name} - Firewall Stats
OUTPUT="{output_path}"
dropped=$(nft list chain inet fw4 input_wan 2>/dev/null | grep -oE 'packets [0-9]+' | awk '{{sum+=$2}}END{{print sum+0}}')
cat > "$OUTPUT" << EOF
{{ "dropped": ${{dropped:-0}}, "last_update": "$(date -Iseconds)" }}
EOF
'''
else:
script_template = f'''#!/bin/sh
# {collector_name} - Network Stats
OUTPUT="{output_path}"
conns=$(wc -l < /proc/net/nf_conntrack 2>/dev/null | tr -cd '0-9')
cat > "$OUTPUT" << EOF
{{ "connections": ${{conns:-0}}, "last_update": "$(date -Iseconds)" }}
EOF
'''
with col2:
st.markdown("**Generated Script:**")
script_code = st.text_area("Script", value=script_template, height=300, key="coll_script")
if st.button("🚀 Deploy Collector", key="deploy_coll"):
script_path = f"/usr/sbin/{collector_name}.sh"
# Save script
with open(f"/tmp/{collector_name}.sh", "w") as f:
f.write(script_code)
os.system(f"scp /tmp/{collector_name}.sh root@192.168.255.1:{script_path}")
os.system(f"ssh root@192.168.255.1 'chmod +x {script_path}'")
# Add cron
cron_entry = f"{cron_schedule} {script_path} >/dev/null 2>&1"
os.system(f'ssh root@192.168.255.1 "grep -q \\"{collector_name}\\" /etc/crontabs/root || echo \\"{cron_entry}\\" >> /etc/crontabs/root"')
st.success(f"Deployed {collector_name} to {script_path}")
# ============== TAB 2: APPS ==============
with tabs[1]:
st.subheader("Streamlit App Deployer")
col1, col2 = st.columns([1, 1])
with col1:
st.markdown("**Create New App**")
app_name = st.text_input("App Name", value="myapp", key="app_name")
app_port = st.number_input("Port", min_value=8500, max_value=9999, value=8520, key="app_port")
app_template = st.selectbox("Template", ["Basic", "Dashboard", "Form", "Data Viewer"])
templates = {
"Basic": '''import streamlit as st
st.set_page_config(page_title="{name}", page_icon="🚀")
st.title("{name}")
st.write("Welcome to {name}!")
''',
"Dashboard": '''import streamlit as st
import json
st.set_page_config(page_title="{name}", page_icon="📊", layout="wide")
st.title("📊 {name} Dashboard")
col1, col2, col3 = st.columns(3)
with col1: st.metric("Metric 1", "100")
with col2: st.metric("Metric 2", "200")
with col3: st.metric("Metric 3", "300")
''',
"Form": '''import streamlit as st
st.set_page_config(page_title="{name}", page_icon="📝")
st.title("📝 {name}")
with st.form("main_form"):
name = st.text_input("Name")
email = st.text_input("Email")
if st.form_submit_button("Submit"):
st.success(f"Submitted: {{name}}, {{email}}")
''',
"Data Viewer": '''import streamlit as st
import json
st.set_page_config(page_title="{name}", page_icon="📈", layout="wide")
st.title("📈 {name}")
data_path = st.text_input("JSON Path", "/tmp/secubox/health-status.json")
if st.button("Load Data"):
try:
with open(data_path) as f:
st.json(json.load(f))
except Exception as e:
st.error(str(e))
'''
}
app_code = templates[app_template].format(name=app_name)
with col2:
st.markdown("**App Code:**")
final_code = st.text_area("Code", value=app_code, height=300, key="app_code")
if st.button("🚀 Deploy App", key="deploy_app"):
app_path = f"/srv/streamlit/apps/{app_name}"
with open(f"/tmp/{app_name}_app.py", "w") as f:
f.write(final_code)
os.system(f"ssh root@192.168.255.1 'mkdir -p {app_path}'")
os.system(f"scp /tmp/{app_name}_app.py root@192.168.255.1:{app_path}/app.py")
# Register in UCI
os.system(f'ssh root@192.168.255.1 "uci set streamlit.{app_name}=instance && uci set streamlit.{app_name}.name={app_name} && uci set streamlit.{app_name}.app={app_name}/app.py && uci set streamlit.{app_name}.port={app_port} && uci set streamlit.{app_name}.enabled=1 && uci commit streamlit"')
st.success(f"Deployed {app_name} on port {app_port}")
st.markdown("---")
st.markdown("**Running Instances:**")
output, _ = ssh_cmd("streamlitctl list 2>/dev/null | head -20")
st.code(output or "No instances found")
# ============== TAB 3: BLOGS ==============
with tabs[2]:
st.subheader("MetaBlogizer Sites")
col1, col2 = st.columns([1, 1])
with col1:
st.markdown("**Create Blog Site**")
blog_name = st.text_input("Site Name", value="myblog", key="blog_name")
blog_domain = st.text_input("Domain", value="myblog.example.com", key="blog_domain")
blog_port = st.number_input("Port", min_value=8900, max_value=9999, value=8920, key="blog_port")
blog_theme = st.selectbox("Theme", ["paper", "ananke", "book", "even", "stack"])
if st.button("🚀 Create Blog", key="create_blog"):
cmd = f'metablogizerctl create {blog_name} {blog_domain} {blog_port} {blog_theme} 2>&1'
output, success = ssh_cmd(cmd, timeout=60)
if success:
st.success(f"Created blog: {blog_name}")
else:
st.error(output)
with col2:
st.markdown("**Existing Sites:**")
sites_output, _ = ssh_cmd("metablogizerctl list 2>/dev/null")
st.code(sites_output or "No sites found")
st.markdown("**Quick Actions:**")
site_select = st.text_input("Site to manage", key="blog_manage")
action_col1, action_col2, action_col3 = st.columns(3)
with action_col1:
if st.button("Build", key="blog_build"):
output, _ = ssh_cmd(f"metablogizerctl build {site_select}")
st.info(output)
with action_col2:
if st.button("Serve", key="blog_serve"):
output, _ = ssh_cmd(f"metablogizerctl serve {site_select}")
st.info(output)
with action_col3:
if st.button("Emancipate", key="blog_emancipate"):
output, _ = ssh_cmd(f"metablogizerctl emancipate {site_select}")
st.info(output)
# ============== TAB 4: STATICS ==============
with tabs[3]:
st.subheader("Static Site Generator")
page_name = st.text_input("Page Name", value="mypage", key="static_name")
page_title = st.text_input("Title", value="My Page", key="static_title")
page_template = st.selectbox("Template", ["Landing", "Status", "Dashboard", "Portal"])
templates = {
"Landing": '''<!DOCTYPE html>
<html><head><title>{title}</title>
<style>body{{background:#0a0a0f;color:#fff;font-family:sans-serif;text-align:center;padding:2rem;}}
h1{{background:linear-gradient(90deg,#0ff,#f0f);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}}
</style></head>
<body><h1>{title}</h1><p>Welcome to {title}</p></body></html>''',
"Status": '''<!DOCTYPE html>
<html><head><title>{title}</title>
<style>body{{background:#0a0a0f;color:#fff;font-family:monospace;padding:1rem;}}
.status{{background:#111;padding:1rem;border-radius:8px;margin:0.5rem 0;}}
</style></head>
<body><h1>{title}</h1>
<div class="status" id="status">Loading...</div>
<script>
fetch('/secubox-status.json').then(r=>r.json()).then(d=>{{
document.getElementById('status').innerHTML = '<pre>'+JSON.stringify(d,null,2)+'</pre>';
}});
</script></body></html>''',
"Dashboard": '''<!DOCTYPE html>
<html><head><title>{title}</title>
<style>body{{background:#0a0a0f;color:#fff;font-family:monospace;padding:1rem;}}
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;}}
.card{{background:#111;padding:1rem;border-radius:8px;text-align:center;}}
</style></head>
<body><h1>{title}</h1>
<div class="grid" id="grid"></div>
<script>
fetch('/secubox-status.json').then(r=>r.json()).then(d=>{{
const r = d.resources || {{}};
document.getElementById('grid').innerHTML =
'<div class="card"><h3>CPU</h3>'+(r.cpu_load||'?')+'</div>'+
'<div class="card"><h3>MEM</h3>'+(r.memory_percent||'?')+'%</div>'+
'<div class="card"><h3>DISK</h3>'+(r.storage_percent||'?')+'%</div>';
}});
</script></body></html>''',
"Portal": '''<!DOCTYPE html>
<html><head><title>{title}</title>
<style>body{{background:#0a0a0f;color:#fff;font-family:monospace;padding:1rem;}}
.links{{display:flex;flex-wrap:wrap;gap:1rem;justify-content:center;}}
a{{background:#222;color:#0ff;padding:1rem 2rem;border-radius:8px;text-decoration:none;}}
a:hover{{background:#333;}}
</style></head>
<body><h1>{title}</h1>
<div class="links">
<a href="/cgi-bin/luci/">LuCI Console</a>
<a href="https://gk2.secubox.in">SecuBox Portal</a>
<a href="https://glances.maegia.tv">Glances</a>
</div></body></html>'''
}
html_code = templates[page_template].format(title=page_title)
final_html = st.text_area("HTML Code", value=html_code, height=300, key="static_html")
if st.button("🚀 Deploy Page", key="deploy_static"):
with open(f"/tmp/{page_name}.html", "w") as f:
f.write(final_html)
os.system(f"scp /tmp/{page_name}.html root@192.168.255.1:/www/{page_name}.html")
st.success(f"Deployed to /www/{page_name}.html")
# ============== TAB 5: SERVICES ==============
with tabs[4]:
st.subheader("Service Exposure (Emancipate)")
col1, col2 = st.columns([1, 1])
with col1:
st.markdown("**Local Services Scan**")
if st.button("🔍 Scan Services", key="scan_svc"):
output, _ = ssh_cmd("netstat -tln | grep LISTEN | awk '{print $4}' | sort -u | head -20")
st.code(output or "No services found")
st.markdown("**Expose Service**")
svc_port = st.number_input("Port", min_value=1, max_value=65535, value=8080, key="svc_port")
svc_domain = st.text_input("Domain", value="myservice.example.com", key="svc_domain")
svc_backend = st.text_input("Backend Name", value="myservice", key="svc_backend")
expose_options = st.multiselect("Exposure Channels", ["HAProxy/SSL", "Tor", "Mesh"])
with col2:
if st.button("🔌 Emancipate Service", key="emancipate_svc"):
# Create HAProxy backend and vhost
if "HAProxy/SSL" in expose_options:
cmds = [
f'uci set haproxy.{svc_backend}=backend',
f'uci set haproxy.{svc_backend}.name="{svc_backend}"',
f'uci set haproxy.{svc_backend}.mode="http"',
f'uci set haproxy.{svc_backend}.balance="roundrobin"',
f'uci set haproxy.{svc_backend}.enabled="1"',
f'uci set haproxy.{svc_backend}.server="srv 192.168.255.1:{svc_port} check"',
f'uci set haproxy.{svc_domain.replace(".", "_")}=vhost',
f'uci set haproxy.{svc_domain.replace(".", "_")}.domain="{svc_domain}"',
f'uci set haproxy.{svc_domain.replace(".", "_")}.backend="{svc_backend}"',
f'uci set haproxy.{svc_domain.replace(".", "_")}.ssl="1"',
f'uci set haproxy.{svc_domain.replace(".", "_")}.https_redirect="1"',
f'uci set haproxy.{svc_domain.replace(".", "_")}.enabled="1"',
'uci commit haproxy',
'haproxyctl generate && haproxyctl reload'
]
for cmd in cmds:
ssh_cmd(cmd)
st.success(f"HAProxy vhost created for {svc_domain}")
if "Tor" in expose_options:
output, _ = ssh_cmd(f"torctl add {svc_backend} {svc_port}")
st.info(f"Tor: {output}")
if "Mesh" in expose_options:
output, _ = ssh_cmd(f"vortexctl mesh publish {svc_backend} {svc_domain}")
st.info(f"Mesh: {output}")
# ============== TAB 6: WIDGETS ==============
with tabs[5]:
st.subheader("Widget Designer")
widget_name = st.text_input("Widget Name", value="mywidget", key="widget_name")
widget_type = st.selectbox("Type", ["Metric", "Chart", "Status", "List"])
data_source = st.text_input("Data JSON Path", value="/secubox-status.json", key="widget_data")
data_field = st.text_input("Data Field", value="resources.cpu_load", key="widget_field")
widget_templates = {
"Metric": '''<div class="widget metric" id="{name}">
<div class="value">--</div>
<div class="label">{name}</div>
</div>
<style>
.widget.metric {{ background:#111; padding:1rem; border-radius:8px; text-align:center; }}
.widget .value {{ font-size:2rem; font-weight:bold; color:#0f0; }}
.widget .label {{ font-size:0.8rem; color:#666; }}
</style>
<script>
fetch('{source}').then(r=>r.json()).then(d=>{{
const v = '{field}'.split('.').reduce((o,k)=>o&&o[k], d);
document.querySelector('#{name} .value').textContent = v || '--';
}});
</script>''',
"Status": '''<div class="widget status" id="{name}">
<span class="dot"></span> {name}
</div>
<style>
.widget.status {{ background:#111; padding:0.5rem 1rem; border-radius:20px; display:inline-flex; align-items:center; gap:0.5rem; }}
.widget .dot {{ width:10px; height:10px; border-radius:50%; background:#f00; }}
.widget .dot.on {{ background:#0f0; box-shadow:0 0 8px #0f0; }}
</style>
<script>
fetch('{source}').then(r=>r.json()).then(d=>{{
const v = '{field}'.split('.').reduce((o,k)=>o&&o[k], d);
if(v) document.querySelector('#{name} .dot').classList.add('on');
}});
</script>''',
"Chart": '''<div class="widget chart" id="{name}">
<canvas id="{name}-canvas"></canvas>
</div>
<style>.widget.chart {{ background:#111; padding:1rem; border-radius:8px; }}</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>/* Add chart.js code here */</script>''',
"List": '''<div class="widget list" id="{name}">
<ul></ul>
</div>
<style>
.widget.list {{ background:#111; padding:1rem; border-radius:8px; }}
.widget.list ul {{ list-style:none; margin:0; padding:0; }}
.widget.list li {{ padding:0.3rem 0; border-bottom:1px solid #222; }}
</style>
<script>
fetch('{source}').then(r=>r.json()).then(d=>{{
const items = '{field}'.split('.').reduce((o,k)=>o&&o[k], d) || [];
const ul = document.querySelector('#{name} ul');
items.forEach(i => {{ ul.innerHTML += '<li>'+JSON.stringify(i)+'</li>'; }});
}});
</script>'''
}
widget_code = widget_templates[widget_type].format(
name=widget_name,
source=data_source,
field=data_field
)
st.markdown("**Preview:**")
st.markdown(f'<div style="background:#0a0a0f;padding:1rem;border-radius:8px;">{widget_code.split("<script")[0]}</div>', unsafe_allow_html=True)
st.markdown("**Generated Code:**")
st.code(widget_code, language="html")
if st.button("📋 Copy to Clipboard", key="copy_widget"):
st.info("Code ready - copy from the code block above")
# ============== TAB 7: EMBEDDER ==============
with tabs[6]:
st.subheader("Service Embedder Portal")
st.markdown("Create unified portal pages embedding multiple services")
col1, col2 = st.columns([1, 2])
with col1:
portal_name = st.text_input("Portal Name", value="myportal", key="embed_name")
portal_title = st.text_input("Portal Title", value="My SecuBox Portal", key="embed_title")
layout = st.selectbox("Layout", ["Grid", "Tabs", "Sidebar"], key="embed_layout")
cols_num = st.slider("Grid Columns", 1, 4, 2, key="embed_cols")
st.markdown("---")
st.markdown("**Available Sources:**")
# Fetch available services
apps_json, _ = ssh_cmd("cat /www/streamlit-instances.json 2>/dev/null")
blogs_json, _ = ssh_cmd("cat /www/metablogizer-sites.json 2>/dev/null")
try:
apps = json.loads(apps_json) if apps_json else []
blogs = json.loads(blogs_json).get("sites", []) if blogs_json else []
except:
apps, blogs = [], []
# Streamlit apps selection
app_options = [f"{a['name']} (:{a['port']})" for a in apps if a.get('running')]
selected_apps = st.multiselect("Streamlit Apps", app_options, key="embed_apps")
# MetaBlogizer sites selection
blog_options = [f"{b['name']} ({b['domain']})" for b in blogs]
selected_blogs = st.multiselect("Blog Sites", blog_options, key="embed_blogs")
# Custom URLs
custom_urls = st.text_area("Custom URLs (one per line)", placeholder="https://glances.maegia.tv\nhttps://grafana.local:3000", key="embed_custom")
with col2:
# Build embed items
embed_items = []
for app_str in selected_apps:
name = app_str.split(" (")[0]
app = next((a for a in apps if a['name'] == name), None)
if app:
embed_items.append({
"name": app['name'],
"url": f"http://192.168.255.1:{app['port']}",
"type": "streamlit"
})
for blog_str in selected_blogs:
name = blog_str.split(" (")[0]
blog = next((b for b in blogs if b['name'] == name), None)
if blog:
embed_items.append({
"name": blog['name'],
"url": f"https://{blog['domain']}",
"type": "blog"
})
for url in (custom_urls or "").strip().split("\n"):
if url.strip():
name = url.strip().split("//")[-1].split("/")[0].split(":")[0]
embed_items.append({
"name": name,
"url": url.strip(),
"type": "custom"
})
# Generate portal HTML based on layout
if layout == "Grid":
items_html = "\n".join([
f'<div class="embed-item"><div class="embed-title">{item["name"]}</div>'
f'<iframe src="{item["url"]}" frameborder="0"></iframe></div>'
for item in embed_items
])
portal_html = f'''<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{portal_title}</title>
<style>
:root {{ --bg:#0a0a0f; --card:#12121a; --accent:#00d4aa; --accent2:#ff00ff; }}
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ background:var(--bg); color:#e0e0e0; font-family:monospace; padding:1rem; }}
h1 {{ text-align:center; background:linear-gradient(90deg,var(--accent),var(--accent2));
-webkit-background-clip:text; -webkit-text-fill-color:transparent; margin-bottom:1rem; }}
.grid {{ display:grid; grid-template-columns:repeat({cols_num}, 1fr); gap:1rem; }}
.embed-item {{ background:var(--card); border-radius:12px; overflow:hidden; }}
.embed-title {{ padding:0.5rem 1rem; background:#1a1a24; font-weight:bold; color:var(--accent); }}
.embed-item iframe {{ width:100%; height:500px; background:#000; }}
@media (max-width:800px) {{ .grid {{ grid-template-columns:1fr; }} }}
</style>
</head><body>
<h1>{portal_title}</h1>
<div class="grid">
{items_html}
</div>
</body></html>'''
elif layout == "Tabs":
tabs_html = "\n".join([f'<button class="tab" onclick="showTab(\'{item["name"]}\')">{item["name"]}</button>' for item in embed_items])
frames_html = "\n".join([f'<iframe id="frame-{item["name"]}" class="frame" src="{item["url"]}" style="display:none"></iframe>' for item in embed_items])
portal_html = f'''<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>{portal_title}</title>
<style>
body {{ background:#0a0a0f; color:#e0e0e0; font-family:monospace; margin:0; }}
.header {{ background:#12121a; padding:1rem; text-align:center; }}
h1 {{ background:linear-gradient(90deg,#00d4aa,#ff00ff); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }}
.tabs {{ display:flex; gap:0.5rem; justify-content:center; margin-top:1rem; flex-wrap:wrap; }}
.tab {{ background:#1a1a24; border:none; color:#888; padding:0.5rem 1rem; border-radius:20px; cursor:pointer; }}
.tab:hover, .tab.active {{ background:#00d4aa; color:#000; }}
.frame {{ width:100%; height:calc(100vh - 120px); border:none; background:#000; }}
</style>
</head><body>
<div class="header">
<h1>{portal_title}</h1>
<div class="tabs">{tabs_html}</div>
</div>
{frames_html}
<script>
function showTab(name) {{
document.querySelectorAll('.frame').forEach(f => f.style.display = 'none');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('frame-' + name).style.display = 'block';
event.target.classList.add('active');
}}
if (document.querySelector('.tab')) document.querySelector('.tab').click();
</script>
</body></html>'''
else: # Sidebar
first_url = embed_items[0]['url'] if embed_items else 'about:blank'
nav_html = "\n".join([f'<a href="#" onclick="loadFrame(\'{item["url"]}\');return false;">{item["name"]}</a>' for item in embed_items])
portal_html = f'''<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>{portal_title}</title>
<style>
body {{ background:#0a0a0f; color:#e0e0e0; font-family:monospace; margin:0; display:flex; }}
.sidebar {{ width:200px; background:#12121a; height:100vh; padding:1rem; }}
.sidebar h2 {{ font-size:1rem; color:#00d4aa; margin-bottom:1rem; }}
.sidebar a {{ display:block; color:#888; text-decoration:none; padding:0.5rem; border-radius:6px; margin:0.2rem 0; }}
.sidebar a:hover {{ background:#1a1a24; color:#fff; }}
.main {{ flex:1; }}
.main iframe {{ width:100%; height:100vh; border:none; background:#000; }}
</style>
</head><body>
<div class="sidebar">
<h2>{portal_title}</h2>
{nav_html}
</div>
<div class="main">
<iframe id="mainframe" src="{first_url}"></iframe>
</div>
<script>
function loadFrame(url) {{ document.getElementById('mainframe').src = url; }}
</script>
</body></html>'''
st.markdown("**Preview:**")
if embed_items:
st.markdown(f"Embedding {len(embed_items)} services: {', '.join(i['name'] for i in embed_items)}")
st.code(portal_html[:2000] + ("..." if len(portal_html) > 2000 else ""), language="html")
else:
st.info("Select services to embed")
if st.button("🚀 Deploy Portal", key="deploy_portal"):
if embed_items:
with open(f"/tmp/{portal_name}.html", "w") as f:
f.write(portal_html)
os.system(f"scp /tmp/{portal_name}.html root@192.168.255.1:/www/{portal_name}.html")
st.success(f"Deployed portal to /www/{portal_name}.html")
st.markdown(f"**Access:** http://192.168.255.1/{portal_name}.html")
else:
st.warning("Please select at least one service to embed")
# Footer
st.markdown("---")
st.markdown(f'<div style="text-align:center;color:#444;font-size:0.7rem;">SecuBox Fabricator • {datetime.now().strftime("%H:%M:%S")}</div>', unsafe_allow_html=True)