Files
Escalante-Pastor/web_app.py
2026-06-26 18:15:55 -03:00

579 lines
26 KiB
Python

"""Interface web local em HTML para o planejador diário."""
from __future__ import annotations
import json
import mimetypes
import os
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
import pandas as pd
from src.planejador_missao.main import executar_planejamento
from src.planejador_missao.quadrinhos import quadrinhos_para_api
from src.planejador_missao.utils import normalizar_texto, parse_bool
BASE_DIR = Path(__file__).resolve().parent
DADOS_DIR = BASE_DIR / "dados"
HOST = "127.0.0.1"
PORT = 8050
def read_csv(path: Path, default: pd.DataFrame) -> pd.DataFrame:
if not path.exists():
return default.copy()
return pd.read_csv(path).fillna("")
def load_state() -> dict:
parametros_default = pd.DataFrame(
{
"parametro": ["data_planejamento", "criterio_missao", "criterio_sbv"],
"valor": ["hoje", "meta_50", "equalizar_quadrinhos"],
}
)
parametros_df = read_csv(DADOS_DIR / "parametros_missao.csv", parametros_default)
parametros = dict(zip(parametros_df["parametro"], parametros_df["valor"]))
aeronaves_default = pd.DataFrame(
{
"aeronave": ["C98", "C97", "C95"],
"disponivel_sede": [True, True, True],
"em_pane": [False, False, False],
"em_missao_rota": [False, False, False],
"hora_livre": ["08:00", "08:00", "08:00"],
}
)
aeronaves = read_csv(DADOS_DIR / "aeronaves_disponiveis.csv", aeronaves_default)
for col in ["disponivel_sede", "em_pane", "em_missao_rota"]:
aeronaves[col] = aeronaves[col].map(parse_bool)
rota_default = pd.DataFrame(
{
"origem": ["NT"],
"destino": [""],
"inicio": [pd.Timestamp.today().strftime("%Y-%m-%d 08:00")],
"fim": [pd.Timestamp.today().strftime("%Y-%m-%d 17:00")],
"tev_horas": [0],
"pernoite_dias": [0],
"rota": [""],
}
)
rotas = read_csv(DADOS_DIR / "rotas_acionadas.csv", rota_default)
rota = rota_default.iloc[0].to_dict() if rotas.empty else rotas.iloc[0].to_dict()
trips_default = pd.DataFrame({"tripulante": [], "disponivel": []})
tripulantes = read_csv(DADOS_DIR / "tripulantes_disponiveis.csv", trips_default)
if not tripulantes.empty:
tripulantes["tripulante"] = tripulantes["tripulante"].map(normalizar_texto)
tripulantes["disponivel"] = tripulantes["disponivel"].map(parse_bool)
tripulantes = tripulantes.sort_values("tripulante")
return {
"parametros": {
"data_planejamento": str(parametros.get("data_planejamento", "hoje")),
"criterio_missao": str(parametros.get("criterio_missao", "meta_50")),
"criterio_sbv": str(parametros.get("criterio_sbv", "equalizar_quadrinhos")),
},
"aeronaves": aeronaves.to_dict(orient="records"),
"rota": rota,
"tem_rota": str(rota.get("destino", "")).strip() != "",
"tripulantes": tripulantes.to_dict(orient="records"),
}
def save_state(payload: dict) -> None:
parametros = payload.get("parametros", {})
pd.DataFrame(
{
"parametro": ["data_planejamento", "criterio_missao", "criterio_sbv"],
"valor": [
parametros.get("data_planejamento", "hoje"),
parametros.get("criterio_missao", "meta_50"),
parametros.get("criterio_sbv", "equalizar_quadrinhos"),
],
}
).to_csv(DADOS_DIR / "parametros_missao.csv", index=False)
pd.DataFrame(payload.get("aeronaves", [])).to_csv(DADOS_DIR / "aeronaves_disponiveis.csv", index=False)
if payload.get("tem_rota", False):
rota = payload.get("rota", {})
pd.DataFrame(
{
"origem": [normalizar_texto(rota.get("origem", "NT"))],
"destino": [normalizar_texto(rota.get("destino", ""))],
"inicio": [str(rota.get("inicio", "")).strip()],
"fim": [str(rota.get("fim", "")).strip()],
"tev_horas": [str(rota.get("tev_horas", "0")).strip()],
"pernoite_dias": [str(rota.get("pernoite_dias", "0")).strip()],
"rota": [normalizar_texto(rota.get("rota", ""))],
}
).to_csv(DADOS_DIR / "rotas_acionadas.csv", index=False)
else:
pd.DataFrame(columns=["origem", "destino", "inicio", "fim", "tev_horas", "pernoite_dias", "rota"]).to_csv(
DADOS_DIR / "rotas_acionadas.csv", index=False
)
tripulantes = pd.DataFrame(payload.get("tripulantes", []))
if not tripulantes.empty:
tripulantes["tripulante"] = tripulantes["tripulante"].map(normalizar_texto)
tripulantes.to_csv(DADOS_DIR / "tripulantes_disponiveis.csv", index=False)
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/":
self._send_html(INDEX_HTML)
return
if path == "/api/state":
self._send_json(load_state())
return
if path == "/api/quadrinhos":
self._send_json(quadrinhos_para_api(BASE_DIR))
return
if path.startswith("/assets/"):
self._send_file(DADOS_DIR / "assets" / path.removeprefix("/assets/"))
return
self.send_error(404)
def do_POST(self) -> None:
path = urlparse(self.path).path
payload = self._read_json()
if path == "/api/save":
save_state(payload)
self._send_json({"ok": True, "message": "Entradas salvas em dados/."})
return
if path == "/api/run":
save_state(payload)
resultado = executar_planejamento(BASE_DIR)
self._send_json(
{
"ok": True,
"message": "Escala gerada com sucesso.",
"data_planejamento": str(resultado["data_planejamento"]),
"arquivo_saida": str(resultado["arquivo_saida"]),
"total_candidatas": resultado["total_candidatas"],
"total_selecionadas": resultado["total_selecionadas"],
}
)
return
if path == "/api/open-latest":
arquivos = sorted((BASE_DIR / "resultados").glob("planejamento_diario_*.xlsx"), key=lambda p: p.stat().st_mtime)
if not arquivos:
self._send_json({"ok": False, "message": "Nenhuma planilha encontrada."}, status=404)
return
os.startfile(arquivos[-1])
self._send_json({"ok": True, "message": f"Abrindo {arquivos[-1]}"})
return
self.send_error(404)
def _read_json(self) -> dict:
length = int(self.headers.get("Content-Length", "0"))
if length == 0:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
def _send_json(self, payload: dict, status: int = 200) -> None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_html(self, html: str) -> None:
data = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_file(self, path: Path) -> None:
try:
resolved = path.resolve()
assets_dir = (DADOS_DIR / "assets").resolve()
if not resolved.is_file() or assets_dir not in resolved.parents:
self.send_error(404)
return
data = resolved.read_bytes()
content_type = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
except OSError:
self.send_error(404)
def log_message(self, format: str, *args) -> None:
return
INDEX_HTML = r"""<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Planejador Diário de Missões</title>
<link rel="icon" href="/assets/favicon.ico">
<style>
:root {
--ink:#172033; --muted:#667085; --line:#d7deea; --brand:#1f4e78; --brand-2:#2c6b9f;
--bg:#eef3f8; --panel:#ffffff; --soft:#f7fafc; --ok:#26734d; --ok-2:#1d6b46; --warn:#9a3412;
--shadow:0 12px 28px rgba(23,32,51,.10); --shadow-soft:0 5px 16px rgba(23,32,51,.07);
}
* { box-sizing: border-box; }
body { margin:0; font-family: Segoe UI, Arial, sans-serif; color:var(--ink); background:var(--bg); }
header { background:linear-gradient(135deg, #12334f, #1f4e78 58%, #2f7a9f); color:white; padding:18px 26px; box-shadow:var(--shadow); }
.topbar { max-width:1280px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; gap:18px; }
.brand { display:flex; align-items:center; gap:14px; min-width:0; }
.brand img { width:48px; height:48px; object-fit:contain; background:white; border-radius:6px; padding:5px; }
h1 { font-size:22px; line-height:1.15; margin:0; color:white; letter-spacing:0; }
.subtitle { margin-top:4px; color:#dceaf5; font-size:13px; }
main { padding:22px 24px 32px; max-width:1280px; margin:0 auto; }
.grid { display:grid; grid-template-columns: minmax(0, 1fr) minmax(360px, .9fr); gap:18px; align-items:start; }
section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:18px; box-shadow:var(--shadow-soft); }
h2 { font-size:15px; margin:0 0 14px; color:var(--brand); display:flex; align-items:center; gap:8px; }
h2::before { content:""; width:4px; height:18px; border-radius:4px; background:var(--brand-2); display:inline-block; }
label { display:block; font-size:12px; font-weight:700; color:#344054; margin-bottom:6px; }
input, select { width:100%; padding:10px 11px; border:1px solid #c7d3e0; border-radius:6px; font-size:14px; background:white; color:var(--ink); outline:none; transition:border-color .15s, box-shadow .15s; }
input:focus, select:focus { border-color:var(--brand-2); box-shadow:0 0 0 3px rgba(47,122,159,.14); }
.row { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:12px; margin-bottom:12px; }
.check { display:flex; align-items:center; gap:8px; margin:9px 0; font-size:14px; color:#26364a; }
.check input { width:16px; height:16px; accent-color:var(--brand); }
.aircraft { border-top:1px solid var(--line); padding-top:13px; margin-top:13px; }
.aircraft:first-of-type { border-top:0; padding-top:0; margin-top:0; }
.aircraft strong { display:block; margin-bottom:9px; color:#122b43; font-size:14px; }
.crewbar { display:flex; gap:8px; margin-bottom:10px; }
button { border:0; border-radius:6px; padding:10px 14px; font-weight:700; cursor:pointer; background:#e7edf3; color:#172033; transition:transform .12s, box-shadow .12s, background .12s; }
button:hover { transform:translateY(-1px); box-shadow:0 5px 12px rgba(23,32,51,.13); }
button.primary { background:#ffffff; color:var(--brand); }
button.success { background:var(--ok); color:white; }
main button.primary { background:var(--brand); color:white; }
main button.success { background:var(--ok-2); color:white; }
button:disabled { opacity:.55; cursor:wait; }
.crew { height:290px; overflow:auto; border:1px solid var(--line); border-radius:8px; padding:10px; display:grid; grid-template-columns: repeat(4, minmax(78px, 1fr)); gap:6px 10px; background:var(--soft); }
.crew label { display:flex; align-items:center; gap:7px; font-size:13px; font-weight:600; margin:0; background:white; border:1px solid #e1e8f0; border-radius:5px; padding:6px 7px; }
.crew input { width:15px; height:15px; accent-color:var(--brand); }
pre { white-space:pre-wrap; background:#101928; color:#dbe7f2; border-radius:8px; padding:15px; min-height:132px; margin:0; font-size:13px; line-height:1.5; border:1px solid #26374d; }
.actions { display:flex; flex-wrap:wrap; gap:10px; }
.tabs { display:flex; gap:8px; margin-bottom:18px; border-bottom:1px solid var(--line); }
.tab { border-radius:7px 7px 0 0; background:#dfe8f2; color:#1f2937; padding:10px 14px; box-shadow:none; }
.tab:hover { box-shadow:none; }
.tab.active { background:var(--brand); color:white; }
.view { display:none; }
.view.active { display:block; }
.summary { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:10px; margin-bottom:12px; }
.metric { border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--soft); color:#475467; font-weight:700; }
.metric b { display:block; color:var(--brand); font-size:20px; margin-top:3px; }
.toolbar { display:grid; grid-template-columns: 1.5fr 180px 180px; gap:10px; margin-bottom:10px; }
.tablewrap { overflow:auto; border:1px solid var(--line); border-radius:8px; background:white; max-height:520px; }
table { width:100%; border-collapse:collapse; font-size:13px; min-width:980px; }
th, td { border-bottom:1px solid var(--line); padding:9px 10px; text-align:left; vertical-align:top; }
th { background:#e8f0f8; color:#243b53; position:sticky; top:0; z-index:1; }
tbody tr:nth-child(even) { background:#fafcff; }
td.num { text-align:right; font-variant-numeric: tabular-nums; }
@media (max-width: 900px) { .topbar { align-items:flex-start; flex-direction:column; } .grid { grid-template-columns:1fr; } .crew { grid-template-columns: repeat(2, minmax(80px, 1fr)); } }
@media (max-width: 900px) { .summary, .toolbar { grid-template-columns:1fr; } }
</style>
</head>
<body>
<header>
<div class="topbar">
<div class="brand">
<img src="/assets/app_logo.png" alt="">
<div>
<h1>Planejador Diário de Missões</h1>
<div class="subtitle">Escala, sobreaviso e acompanhamento operacional</div>
</div>
</div>
<div class="actions">
<button onclick="save()">Salvar entradas</button>
<button class="primary" onclick="run()">Gerar escala</button>
<button class="success" onclick="openLatest()">Abrir planilha</button>
</div>
</div>
</header>
<main>
<div class="tabs">
<button class="tab active" data-view="planejamento" onclick="showView('planejamento')">Planejamento</button>
<button class="tab" data-view="quadrinhos" onclick="showView('quadrinhos')">Quadrinhos</button>
</div>
<div id="planejamento" class="view active">
<div class="grid">
<section>
<h2>Parâmetros</h2>
<div class="row">
<div><label>Data</label><input id="data_planejamento" placeholder="hoje ou AAAA-MM-DD"></div>
<div><label>Critério missão</label><select id="criterio_missao"><option>meta_50</option><option>meta_110</option><option>menor_custo</option></select></div>
</div>
<div class="row">
<div><label>Critério SBV</label><select id="criterio_sbv"><option>equalizar_quadrinhos</option><option>meta_50</option><option>meta_110</option></select></div>
</div>
</section>
<section>
<h2>Aeronaves</h2>
<div id="aeronaves"></div>
</section>
<section>
<h2>Missão acionada</h2>
<label class="check"><input id="tem_rota" type="checkbox"> Existe missão de rota acionada</label>
<div class="row">
<div><label>Origem</label><input id="origem"></div>
<div><label>Destino</label><input id="destino"></div>
</div>
<div class="row">
<div><label>Início</label><input id="inicio" placeholder="AAAA-MM-DD HH:MM"></div>
<div><label>Fim</label><input id="fim" placeholder="AAAA-MM-DD HH:MM"></div>
</div>
<div class="row">
<div><label>TEV horas</label><input id="tev_horas"></div>
<div><label>Pernoites</label><input id="pernoite_dias"></div>
</div>
<div><label>Rota resumida</label><input id="rota"></div>
</section>
<section>
<h2>Tripulantes disponíveis</h2>
<div class="crewbar">
<button onclick="setCrew(true)">Marcar todos</button>
<button onclick="setCrew(false)">Desmarcar todos</button>
</div>
<div id="tripulantes" class="crew"></div>
</section>
<section style="grid-column: 1 / -1;">
<h2>Status</h2>
<pre id="log">Carregando dados...</pre>
</section>
</div>
</div>
<div id="quadrinhos" class="view">
<section>
<h2>Quadrinho operacional</h2>
<div class="summary">
<div class="metric">Tripulantes<b id="qtd_trip">-</b></div>
<div class="metric">Quadrinhos abaixo de 50h<b id="qtd_abaixo50">-</b></div>
<div class="metric">Média de horas no ano<b id="media_horas">-</b></div>
</div>
<div class="toolbar">
<input id="q_busca" placeholder="Filtrar por tripulante, OI ou observação" oninput="renderQuadrinhos()">
<select id="q_aeronave" onchange="renderQuadrinhos()"><option value="">Todas aeronaves</option><option>C98</option><option>C97</option><option>C95</option></select>
<select id="q_meta" onchange="renderQuadrinhos()"><option value="">Todos</option><option value="abaixo50">Abaixo de 50h</option></select>
</div>
<div class="tablewrap">
<table>
<thead>
<tr>
<th>Trip</th><th>Aeronave</th><th>Qualificação</th><th>Próxima OI</th>
<th>Horas base</th><th>Horas ano</th><th>SBV</th><th>Faltam 50h</th><th>Último voo</th><th>Obs</th>
</tr>
</thead>
<tbody id="quadrinhos_body"><tr><td colspan="10">Carregando...</td></tr></tbody>
</table>
</div>
</section>
</div>
</main>
<script>
let state = null;
let quadrinhos = [];
async function load() {
const res = await fetch('/api/state');
state = await res.json();
document.querySelector('#data_planejamento').value = state.parametros.data_planejamento;
document.querySelector('#criterio_missao').value = state.parametros.criterio_missao;
document.querySelector('#criterio_sbv').value = state.parametros.criterio_sbv;
document.querySelector('#tem_rota').checked = state.tem_rota;
for (const key of ['origem','destino','inicio','fim','tev_horas','pernoite_dias','rota']) {
document.querySelector('#' + key).value = state.rota[key] ?? '';
}
renderAircraft();
renderCrew();
log('Pronto. Edite as entradas e clique em Salvar e gerar escala.');
loadQuadrinhos();
}
function showView(name) {
for (const view of document.querySelectorAll('.view')) view.classList.toggle('active', view.id === name);
for (const tab of document.querySelectorAll('.tab')) tab.classList.toggle('active', tab.dataset.view === name);
if (name === 'quadrinhos' && quadrinhos.length === 0) loadQuadrinhos();
}
async function loadQuadrinhos() {
const res = await fetch('/api/quadrinhos');
const data = await res.json();
quadrinhos = data.linhas ?? [];
document.querySelector('#qtd_trip').textContent = data.resumo?.tripulantes ?? 0;
document.querySelector('#qtd_abaixo50').textContent = data.resumo?.abaixo_50 ?? 0;
document.querySelector('#media_horas').textContent = `${data.resumo?.horas_media ?? 0} h`;
renderQuadrinhos();
}
function renderQuadrinhos() {
const root = document.querySelector('#quadrinhos_body');
const busca = (document.querySelector('#q_busca')?.value ?? '').trim().toUpperCase();
const aeronave = document.querySelector('#q_aeronave')?.value ?? '';
const meta = document.querySelector('#q_meta')?.value ?? '';
const rows = quadrinhos.filter((q) => {
const texto = `${q.tripulante} ${q.aeronave} ${q.qualificacao} ${q.proxima_oi} ${q.observacao}`.toUpperCase();
return (!busca || texto.includes(busca)) && (!aeronave || q.aeronave === aeronave) && (!meta || Number(q.horas_voadas_ano) < 50);
});
if (rows.length === 0) {
root.innerHTML = '<tr><td colspan="10">Nenhum quadrinho encontrado.</td></tr>';
return;
}
root.innerHTML = rows.map((q) => `
<tr>
<td>${q.tripulante}</td>
<td>${q.aeronave}</td>
<td>${q.qualificacao}</td>
<td>${q.proxima_oi}</td>
<td class="num">${fmt(q.horas_aeronave_base)}</td>
<td class="num">${fmt(q.horas_voadas_ano)}</td>
<td class="num">${fmt(q.sbv_ano)}</td>
<td class="num">${fmt(q.meta_50_faltante)}</td>
<td>${q.ultima_data_voo ?? ''}</td>
<td>${q.observacao ?? ''}</td>
</tr>
`).join('');
}
function fmt(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return n.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
}
function renderAircraft() {
const root = document.querySelector('#aeronaves');
root.innerHTML = '';
for (const av of state.aeronaves) {
const item = document.createElement('div');
item.className = 'aircraft';
item.innerHTML = `
<strong>${av.aeronave}</strong>
<label class="check"><input type="checkbox" data-av="${av.aeronave}" data-field="disponivel_sede" ${av.disponivel_sede ? 'checked' : ''}> Disponível em sede</label>
<label class="check"><input type="checkbox" data-av="${av.aeronave}" data-field="em_pane" ${av.em_pane ? 'checked' : ''}> Em pane</label>
<label class="check"><input type="checkbox" data-av="${av.aeronave}" data-field="em_missao_rota" ${av.em_missao_rota ? 'checked' : ''}> Em missão de rota</label>
<label>Hora livre</label><input value="${av.hora_livre ?? ''}" data-av="${av.aeronave}" data-field="hora_livre">
`;
root.appendChild(item);
}
}
function renderCrew() {
const root = document.querySelector('#tripulantes');
root.innerHTML = '';
for (const trip of state.tripulantes) {
const label = document.createElement('label');
label.innerHTML = `<input type="checkbox" data-trip="${trip.tripulante}" ${trip.disponivel ? 'checked' : ''}> ${trip.tripulante}`;
root.appendChild(label);
}
}
function collect() {
const payload = {
parametros: {
data_planejamento: document.querySelector('#data_planejamento').value,
criterio_missao: document.querySelector('#criterio_missao').value,
criterio_sbv: document.querySelector('#criterio_sbv').value,
},
tem_rota: document.querySelector('#tem_rota').checked,
rota: {},
aeronaves: [],
tripulantes: [],
};
for (const key of ['origem','destino','inicio','fim','tev_horas','pernoite_dias','rota']) {
payload.rota[key] = document.querySelector('#' + key).value;
}
for (const av of state.aeronaves) {
const out = { aeronave: av.aeronave };
for (const field of ['disponivel_sede','em_pane','em_missao_rota']) {
out[field] = document.querySelector(`[data-av="${av.aeronave}"][data-field="${field}"]`).checked;
}
out.hora_livre = document.querySelector(`[data-av="${av.aeronave}"][data-field="hora_livre"]`).value;
payload.aeronaves.push(out);
}
for (const input of document.querySelectorAll('[data-trip]')) {
payload.tripulantes.push({ tripulante: input.dataset.trip, disponivel: input.checked });
}
return payload;
}
async function save() {
setBusy(true);
try {
const res = await fetch('/api/save', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(collect()) });
const data = await res.json();
log(data.message);
} finally {
setBusy(false);
}
}
async function run() {
setBusy(true);
log('Gerando escala. Aguarde...');
try {
const res = await fetch('/api/run', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(collect()) });
const data = await res.json();
if (!data.ok) throw new Error(data.message);
log(`${data.message}\nData: ${data.data_planejamento}\nColunas candidatas: ${data.total_candidatas}\nEscalas selecionadas: ${data.total_selecionadas}\nArquivo: ${data.arquivo_saida}`);
loadQuadrinhos();
} catch (err) {
log('Erro ao gerar escala:\n' + err.message);
} finally {
setBusy(false);
}
}
async function openLatest() {
const res = await fetch('/api/open-latest', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
const data = await res.json();
log(data.message);
}
function setCrew(value) {
for (const input of document.querySelectorAll('[data-trip]')) input.checked = value;
}
function setBusy(value) {
for (const btn of document.querySelectorAll('button')) btn.disabled = value;
}
function log(text) {
document.querySelector('#log').textContent = text;
}
load();
</script>
</body>
</html>
"""
def main() -> None:
server = ThreadingHTTPServer((HOST, PORT), Handler)
url = f"http://{HOST}:{PORT}"
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
print(f"Interface web aberta em {url}")
print("Pressione Ctrl+C para encerrar.")
server.serve_forever()
if __name__ == "__main__":
main()