diff --git a/app.py b/app.py
index fbacedc..8e4ff83 100644
--- a/app.py
+++ b/app.py
@@ -9,7 +9,7 @@ import time
import io
import os
-# Initialize database
+# Initialize database schemas
db.init_db()
# Set up page configurations
@@ -80,7 +80,7 @@ custom_css = """
border-radius: 8px;
}
- /* Buttons with neon cyan gradient */
+ /* Buttons styling */
div.stButton > button, div.stDownloadButton > button {
background: linear-gradient(135deg, #0052CC 0%, #00D2FF 100%) !important;
color: #FFFFFF !important;
@@ -93,6 +93,7 @@ custom_css = """
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.05em;
+ width: 100%;
}
div.stButton > button:hover, div.stDownloadButton > button:hover {
background: linear-gradient(135deg, #00D2FF 0%, #0052CC 100%) !important;
@@ -100,6 +101,29 @@ custom_css = """
box-shadow: 0 6px 22px rgba(0, 210, 255, 0.5) !important;
}
+ /* Badges */
+ .badge {
+ display: inline-block;
+ padding: 0.25em 0.6em;
+ font-size: 80%;
+ font-weight: 700;
+ line-height: 1;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ border-radius: 6px;
+ }
+ .badge-success {
+ background-color: rgba(0, 200, 83, 0.2);
+ color: #00C853;
+ border: 1px solid #00C853;
+ }
+ .badge-warning {
+ background-color: rgba(255, 171, 0, 0.2);
+ color: #FFAB00;
+ border: 1px solid #FFAB00;
+ }
+
/* Tabs selector customization */
button[data-baseweb="tab"] {
color: #94A3B8 !important;
@@ -126,11 +150,98 @@ custom_css = """
"""
st.markdown(custom_css, unsafe_allow_html=True)
-# Sidebar Design
-st.sidebar.markdown("
NBU Insights
", unsafe_allow_html=True)
-st.sidebar.markdown("Docker Cloud Dashboard
", unsafe_allow_html=True)
+# Initialize Authentication Session States
+if 'logged_in' not in st.session_state:
+ st.session_state['logged_in'] = False
+if 'username' not in st.session_state:
+ st.session_state['username'] = ""
+if 'role' not in st.session_state:
+ st.session_state['role'] = ""
-# Global view selector
+# --- ROUTING ENGINE ---
+
+# Scenario 1: First Run Bootstrap (No Administrator exists)
+if not db.has_admin_user():
+ st.markdown("", unsafe_allow_html=True)
+ st.markdown("
⚙️ Configuração Inicial do Sistema
", unsafe_allow_html=True)
+ st.markdown("
Nenhum usuário administrador encontrado no banco de dados. Crie o Administrador Principal para continuar.
", unsafe_allow_html=True)
+ st.markdown("
", unsafe_allow_html=True)
+
+ col_boot1, col_boot2, col_boot3 = st.columns([1, 1.3, 1])
+ with col_boot2:
+ with st.form("bootstrap_form"):
+ admin_user = st.text_input("Usuário Administrador:", placeholder="Ex: admin")
+ admin_pass = st.text_input("Senha do Administrador:", type="password", placeholder="Digite uma senha robusta")
+ admin_pass_confirm = st.text_input("Confirme a Senha:", type="password", placeholder="Confirme a senha")
+ submit_bootstrap = st.form_submit_button("Criar Administrador Principal")
+
+ if submit_bootstrap:
+ if not admin_user.strip():
+ st.error("O nome de usuário não pode estar em branco.")
+ elif len(admin_pass) < 6:
+ st.error("A senha deve conter pelo menos 6 caracteres.")
+ elif admin_pass != admin_pass_confirm:
+ st.error("As senhas informadas não coincidem.")
+ else:
+ success = db.create_user(admin_user, admin_pass, role='admin')
+ if success:
+ st.success("Administrador Principal cadastrado com sucesso! Recarregando...")
+ time.sleep(1.5)
+ st.rerun()
+ else:
+ st.error("Erro ao cadastrar. O usuário já existe ou ocorreu um erro de persistência.")
+ st.stop()
+
+# Scenario 2: Unauthenticated User (Login Gate)
+if not st.session_state['logged_in']:
+ col_login1, col_login2, col_login3 = st.columns([1, 1.2, 1])
+ with col_login2:
+ st.markdown("", unsafe_allow_html=True)
+ st.markdown("
⚡ NetBackup Log Insights
", unsafe_allow_html=True)
+ st.markdown("
Faça login para gerenciar as rotinas de backup e mitigações
", unsafe_allow_html=True)
+ st.markdown("
", unsafe_allow_html=True)
+
+ with st.form("login_form"):
+ login_user = st.text_input("Usuário:", placeholder="Nome do usuário")
+ login_pass = st.text_input("Senha:", type="password", placeholder="Senha de acesso")
+ submit_login = st.form_submit_button("Entrar")
+
+ if submit_login:
+ user_record = db.get_user(login_user)
+ if user_record and db.verify_password(user_record['password_hash'], login_pass):
+ st.session_state['logged_in'] = True
+ st.session_state['username'] = user_record['username']
+ st.session_state['role'] = user_record['role']
+ st.toast(f"Bem-vindo, {user_record['username']}!", icon="👋")
+ time.sleep(0.5)
+ st.rerun()
+ else:
+ st.error("Usuário ou senha inválidos.")
+ st.stop()
+
+# --- APP LAYOUT (Authenticated Scope) ---
+
+# Sidebar Profile Header
+st.sidebar.markdown(f"""
+
+
Técnico Autenticado
+
{st.session_state['username'].upper()}
+
+ {st.session_state["role"].upper()}
+
+
+""", unsafe_allow_html=True)
+
+# Logout Button
+if st.sidebar.button("Encerrar Sessão (Sair)"):
+ st.session_state['logged_in'] = False
+ st.session_state['username'] = ""
+ st.session_state['role'] = ""
+ st.rerun()
+
+st.sidebar.markdown("---")
+
+# Global zone selector
st.sidebar.subheader("Zone Selector")
server_filter = st.sidebar.selectbox(
"Selecione o escopo:",
@@ -141,7 +252,7 @@ server_filter = st.sidebar.selectbox(
]
)
-# File Ingestion
+# File Ingestion Panel
st.sidebar.subheader("Log Ingestion")
uploaded_file = st.sidebar.file_uploader(
"Importar relatório NetBackup (CSV):",
@@ -170,45 +281,19 @@ if uploaded_file is not None:
st.toast(f"Relatório '{uploaded_file.name}' importado e sincronizado no banco!", icon="✅")
else:
st.info(f"O relatório '{uploaded_file.name}' já foi importado anteriormente. Os dados foram atualizados no banco.")
+ st.rerun()
else:
st.error("O arquivo fornecido está vazio ou mal formatado.")
except Exception as e:
st.error(f"Erro ao processar arquivo: {str(e)}")
-# Demo data load button
-db_jobs = db.get_historical_jobs()
-
-if not db_jobs:
- st.sidebar.markdown("---")
- st.sidebar.write("Sem registros no banco. Deseja carregar dados simulados?")
- if st.sidebar.button("Carregar Dados de Demonstração"):
- demo_data = """###################### TABLE (Job Summary)####################
-Job ID,Client,Policy,Type,Exit Code,Start Time,Finish Time,Duration,MBytes,# of Files,Primary Server,Media Server
-10001,srv-web-01,Prod_Web_Apps,Backup,0,2026-07-13 01:00:00,2026-07-13 01:15:23,00:15:23,"1,540.50",45612,srvpalcvnbu01.elo.corp,mediasrv-01
-10002,srv-db-01,Prod_Database,Backup,2,2026-07-13 01:30:00,2026-07-13 01:45:00,00:15:00,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01
-10003,srv-db-01,Prod_Database,Backup,0,2026-07-13 02:00:00,2026-07-13 02:14:15,00:14:15,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01
-10004,srv-file-02,Corp_Shares,Backup,96,2026-07-13 02:30:00,2026-07-13 03:00:00,00:30:00,"12,050.40",154210,srvpalcocinbupri01.elo.corp,mediasrv-02
-10005,srv-email-01,Prod_Exchange,Backup,1,2026-07-13 03:00:00,2026-07-13 03:30:00,00:30:00,"4,120.30",8912,srvpalcvnbu01.elo.corp,mediasrv-01
-10006,srv-sap-prod,Prod_SAP_ERP,Backup,58,2026-07-13 04:00:00,2026-07-13 04:22:10,00:22:10,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02
-10007,srv-sap-prod,Prod_SAP_ERP,Backup,0,2026-07-13 05:00:00,2026-07-13 05:25:00,00:25:00,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02
-10008,srv-k8s-node1,Prod_Containers,Backup,0,2026-07-13 05:30:00,2026-07-13 06:10:00,00:40:00,"3,240.10",56124,srvpalcvnbu01.elo.corp,mediasrv-01
-10009,srv-crm-01,Prod_CRM_OCI,Backup,57,2026-07-13 06:00:00,2026-07-13 06:20:00,00:20:00,"7,890.00",94125,srvpalcocinbupri01.elo.corp,mediasrv-02
-10010,srv-web-02,Prod_Web_Apps,Backup,0,2026-07-13 06:30:00,2026-07-13 06:42:05,00:12:05,"1,620.40",47120,srvpalcvnbu01.elo.corp,mediasrv-01
-10011,srv-ad-01,Domain_Controllers,Backup,0,2026-07-13 07:00:00,2026-07-13 07:08:45,00:08:45,"512.00",4102,srvpalcvnbu01.elo.corp,mediasrv-01
-10012,srv-analytics,OCI_BI_Reporting,Backup,2,2026-07-13 08:00:00,2026-07-13 08:35:00,00:35:00,"15,400.00",85124,srvpalcocinbupri01.elo.corp,mediasrv-02
-"""
- df_parsed = parse_nbu_csv(demo_data)
- db.save_jobs(df_parsed)
- st.rerun()
-
-# Apply logic and query historical database records
+# Query historical database records
db_jobs = db.get_historical_jobs()
df = pd.DataFrame(db_jobs)
df_filtered = pd.DataFrame()
if not df.empty:
- # Set proper datetime types for sorting/filtering
df['start_time'] = pd.to_datetime(df['start_time'])
df['finish_time'] = pd.to_datetime(df['finish_time'])
@@ -225,43 +310,47 @@ st.markdown("NetBackup Log Insights & Mitigatio
if df.empty:
# Beautiful empty state welcome page
- st.info("👋 Bem-vindo! Carregue um arquivo de log do NetBackup (CSV) na barra lateral esquerda ou clique em 'Carregar Dados de Demonstração' para preencher o banco de dados persistente.")
+ st.info("👋 Banco de dados vazio. Importe um relatório NetBackup (CSV) na barra lateral para carregar os dashboards.")
- col1, col2 = st.columns(2)
- with col1:
- st.markdown("""
- ### Arquitetura de Containers (v2.0)
- * 🐳 **VPS Deploy Ready:** Totalmente empacotado para execução em Docker e orquestração Docker Compose.
- * 💾 **Persistência SQLite:** Banco persistido no volume `/app/data/nbu_insights.db`.
- * 🔄 **UPSERT Engine:** Ingestão incremental garantida baseado na chave `(Job ID)`.
- * 📊 **Deduplicação Inteligente:** Identifica falhas transitórias com correções automáticas em reexecuções de logs posteriores.
- """)
- with col2:
- st.markdown("""
- ### Relatórios Mitigados em PDF
- * 📄 **PDF Export Engine:** Integração com biblioteca `fpdf2`.
- * 📥 **Exportação Rápida:** Gera relatórios em A4 contendo resumos operacionais e o histórico completo de notas técnicas inseridas por engenheiros.
- """)
+ # Demo data loader for Admins
+ if st.session_state['role'] == 'admin':
+ st.write("Deseja testar com dados simulados?")
+ if st.button("Carregar Dados de Demonstração"):
+ demo_data = """###################### TABLE (Job Summary)####################
+Job ID,Client,Policy,Type,Exit Code,Start Time,Finish Time,Duration,MBytes,# of Files,Primary Server,Media Server
+10001,srv-web-01,Prod_Web_Apps,Backup,0,2026-07-13 01:00:00,2026-07-13 01:15:23,00:15:23,"1,540.50",45612,srvpalcvnbu01.elo.corp,mediasrv-01
+10002,srv-db-01,Prod_Database,Backup,2,2026-07-13 01:30:00,2026-07-13 01:45:00,00:15:00,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01
+10003,srv-db-01,Prod_Database,Backup,0,2026-07-13 02:00:00,2026-07-13 02:14:15,00:14:15,"8,420.00",12500,srvpalcvnbu01.elo.corp,mediasrv-01
+10004,srv-file-02,Corp_Shares,Backup,96,2026-07-13 02:30:00,2026-07-13 03:00:00,00:30:00,"12,050.40",154210,srvpalcocinbupri01.elo.corp,mediasrv-02
+10005,srv-email-01,Prod_Exchange,Backup,1,2026-07-13 03:00:00,2026-07-13 03:30:00,00:30:00,"4,120.30",8912,srvpalcvnbu01.elo.corp,mediasrv-01
+10006,srv-sap-prod,Prod_SAP_ERP,Backup,58,2026-07-13 04:00:00,2026-07-13 04:22:10,00:22:10,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02
+10007,srv-sap-prod,Prod_SAP_ERP,Backup,0,2026-07-13 05:00:00,2026-07-13 05:25:00,00:25:00,"45,000.00",301244,srvpalcocinbupri01.elo.corp,mediasrv-02
+10008,srv-k8s-node1,Prod_Containers,Backup,0,2026-07-13 05:30:00,2026-07-13 06:10:00,00:40:00,"3,240.10",56124,srvpalcvnbu01.elo.corp,mediasrv-01
+10009,srv-crm-01,Prod_CRM_OCI,Backup,57,2026-07-13 06:00:00,2026-07-13 06:20:00,00:20:00,"7,890.00",94125,srvpalcocinbupri01.elo.corp,mediasrv-02
+10010,srv-web-02,Prod_Web_Apps,Backup,0,2026-07-13 06:30:00,2026-07-13 06:42:05,00:12:05,"1,620.40",47120,srvpalcvnbu01.elo.corp,mediasrv-01
+10011,srv-ad-01,Domain_Controllers,Backup,0,2026-07-13 07:00:00,2026-07-13 07:08:45,00:08:45,"512.00",4102,srvpalcvnbu01.elo.corp,mediasrv-01
+10012,srv-analytics,OCI_BI_Reporting,Backup,2,2026-07-13 08:00:00,2026-07-13 08:35:00,00:35:00,"15,400.00",85124,srvpalcocinbupri01.elo.corp,mediasrv-02
+"""
+ df_parsed = parse_nbu_csv(demo_data)
+ db.save_jobs(df_parsed)
+ st.rerun()
else:
- # Tabs layout
- tab_dashboard, tab_table, tab_mitigation = st.tabs([
- "📊 Dashboard de Performance",
- "📋 Tabela de Execuções",
- "🛠️ Plano de Ações & Mitigações"
- ])
+ # Setup Tabs (Include Admin Dashboard Tab only for role == 'admin')
+ tab_labels = ["📊 Dashboard de Performance", "📋 Tabela de Execuções", "🛠️ Plano de Ações & Mitigações"]
+ if st.session_state['role'] == 'admin':
+ tab_labels.append("⚙️ Painel de Administração")
+
+ tabs = st.tabs(tab_labels)
# Tab 1: Dashboard
- with tab_dashboard:
- # Calculate dashboard metrics
+ with tabs[0]:
total_jobs = len(df_filtered)
success_jobs = len(df_filtered[df_filtered['exit_code'] <= 1])
success_rate = (success_jobs / total_jobs * 100) if total_jobs > 0 else 0.0
- # Space savings
+ # Calculate MSDP volumes
total_pre_dedup = df_filtered['mbytes'].sum()
-
import hashlib
- # Calculate simulated post-deduplicated sizes
total_post_dedup = 0.0
for _, r in df_filtered.iterrows():
total_post_dedup += r['mbytes'] * (0.15 + (int(hashlib.md5(str(r['job_id']).encode()).hexdigest(), 16) % 11) / 100.0)
@@ -269,14 +358,13 @@ else:
dedup_ratio = (total_pre_dedup / total_post_dedup) if total_post_dedup > 0.0 else 1.0
space_saved = ((1 - (total_post_dedup / total_pre_dedup)) * 100) if total_pre_dedup > 0.0 else 0.0
- # Active failures (Exit Code > 1 AND is_rerun_success == 0 AND status != 'Resolvido')
active_failures = len(df_filtered[
(df_filtered['exit_code'] > 1) &
(df_filtered['is_rerun_success'] == 0) &
(df_filtered['status'] != 'Resolvido')
])
- # Render top KPI metrics row
+ # Render Metrics Cards
col_metric1, col_metric2, col_metric3 = st.columns(3)
with col_metric1:
st.metric(
@@ -299,7 +387,7 @@ else:
delta_color="inverse" if active_failures > 0 else "normal"
)
- # PDF Generation action button
+ # PDF Mitigation Report action
st.markdown("---")
pdf_bytes = rg.generate_mitigation_pdf(df_filtered.to_dict('records'), server_filter)
st.download_button(
@@ -309,31 +397,28 @@ else:
mime="application/pdf"
)
- # Distribution charts
+ # Visual Charts
st.markdown("Visualizações Operacionais
", unsafe_allow_html=True)
col_chart1, col_chart2 = st.columns(2)
with col_chart1:
- # Rerun / Fail categories pie chart
+ # Distribution of Jobs pie chart
categories = []
counts = []
colors = []
- # Successes
suc_count = len(df_filtered[df_filtered['exit_code'] <= 1])
if suc_count > 0:
categories.append("Sucesso (Exit 0/1)")
counts.append(suc_count)
colors.append("#00C853")
- # Failures Reexecuted
reex_count = len(df_filtered[(df_filtered['exit_code'] > 1) & (df_filtered['is_rerun_success'] == 1)])
if reex_count > 0:
categories.append("Falha Reexecutada")
counts.append(reex_count)
colors.append("#00D2FF")
- # Failures Mitigated
mit_count = len(df_filtered[
(df_filtered['exit_code'] > 1) &
(df_filtered['is_rerun_success'] == 0) &
@@ -344,7 +429,6 @@ else:
counts.append(mit_count)
colors.append("#FFAB00")
- # Active Failures
act_count = len(df_filtered[
(df_filtered['exit_code'] > 1) &
(df_filtered['is_rerun_success'] == 0) &
@@ -372,7 +456,6 @@ else:
st.plotly_chart(fig_donut, use_container_width=True)
with col_chart2:
- # Volume written by client
if not df_filtered.empty:
df_grouped = df_filtered.groupby('client')['mbytes'].sum().reset_index()
df_grouped = df_grouped.sort_values(by='mbytes', ascending=False).head(7)
@@ -394,18 +477,17 @@ else:
yaxis=dict(gridcolor='#1E293B')
)
st.plotly_chart(fig_bar, use_container_width=True)
-
+
# Tab 2: Job Table
- with tab_table:
+ with tabs[1]:
st.markdown("Lista Completa de Jobs Ingeridos
", unsafe_allow_html=True)
- # State Filter options
+ # State Filters
error_state_filter = st.selectbox(
- "Visualização de Erros:",
+ "Filtrar Registros por Estado:",
options=["Todos os Registros", "Todos os Erros", "Erros Sem Tratativa / Pendentes", "Erros Corrigidos Automatizados (Reexecutados)"]
)
- # Filter dataframe based on state selection
df_grid = df_filtered.copy()
if error_state_filter == "Todos os Erros":
df_grid = df_grid[df_grid['exit_code'] > 1]
@@ -424,7 +506,6 @@ else:
if df_grid.empty:
st.info("Nenhum registro corresponde ao filtro de erro selecionado.")
else:
- # Build display columns
def get_rerun_badge(row):
code = row['exit_code']
reex = row['is_rerun_success']
@@ -458,11 +539,10 @@ else:
hide_index=True
)
- # Tab 3: Mitigation Register CRUD
- with tab_mitigation:
+ # Tab 3: Mitigation CRUD Panel
+ with tabs[2]:
st.markdown("Registro de Ações Corretivas
", unsafe_allow_html=True)
- # Failed jobs
failed_jobs_df = df_filtered[df_filtered['exit_code'] > 1]
if failed_jobs_df.empty:
@@ -472,7 +552,6 @@ else:
with col_list:
st.markdown("#### Lista de Ocorrências com Erro")
-
failures_list = []
for _, row in failed_jobs_df.iterrows():
jid = int(row['job_id'])
@@ -497,7 +576,6 @@ else:
with col_form:
st.markdown("#### Formulário de Mitigação")
- # Dropdown option
job_options = [
f"{row['job_id']} - {row['client']} ({row['policy']})"
for _, row in failed_jobs_df.iterrows()
@@ -505,7 +583,8 @@ else:
selected_job_option = st.selectbox(
"Selecione o Job com falha para atualizar:",
- options=job_options
+ options=job_options,
+ key="mitigation_select"
)
if selected_job_option:
@@ -521,7 +600,7 @@ else:
* **Reexecutado com Sucesso?** `{"Sim" if job_row['is_rerun_success'] == 1 else "Não"}`
""")
- with st.form(key="mitigation_form_v2", clear_on_submit=False):
+ with st.form(key="mitigation_form_v3", clear_on_submit=False):
action_text = st.text_area(
"Ação Tomada / Nota Técnica:",
value=current_text,
@@ -545,3 +624,70 @@ else:
st.toast("Mitigação registrada e salva no banco!", icon="💾")
time.sleep(0.8)
st.rerun()
+
+ # Tab 4: Admin Panel (Only visible to Administrators)
+ if st.session_state['role'] == 'admin':
+ with tabs[3]:
+ st.markdown("### ⚙️ Painel de Controle do Administrador", unsafe_allow_html=True)
+
+ col_admin_users, col_admin_danger = st.columns([1.2, 1])
+
+ with col_admin_users:
+ st.markdown("#### Gerenciamento de Usuários")
+
+ # Form to create new user
+ with st.form("create_user_form", clear_on_submit=True):
+ new_uname = st.text_input("Nome do Usuário:")
+ new_passwd = st.text_input("Senha:", type="password")
+ new_role = st.selectbox("Perfil de Acesso (Cargo):", options=["user", "admin"])
+ submit_user = st.form_submit_button("Criar Novo Usuário")
+
+ if submit_user:
+ if not new_uname.strip():
+ st.error("O nome de usuário não pode estar em branco.")
+ elif len(new_passwd) < 6:
+ st.error("A senha deve possuir no mínimo 6 caracteres.")
+ else:
+ success = db.create_user(new_uname, new_passwd, role=new_role)
+ if success:
+ st.success(f"Usuário '{new_uname.strip().lower()}' cadastrado com sucesso!")
+ time.sleep(0.8)
+ st.rerun()
+ else:
+ st.error("O nome de usuário já está sendo utilizado.")
+
+ # List users and delete option
+ st.markdown("##### Usuários Cadastrados")
+ users_list = db.get_all_users()
+ st.dataframe(pd.DataFrame(users_list), use_container_width=True, hide_index=True)
+
+ # Delete user selector
+ st.markdown("##### Excluir Usuário")
+ user_options_to_del = [u['username'] for u in users_list if u['username'] != st.session_state['username']]
+ if user_options_to_del:
+ user_to_delete = st.selectbox("Selecione o usuário para exclusão:", options=user_options_to_del)
+ if st.button("Excluir Usuário Selecionado"):
+ db.delete_user(user_to_delete)
+ st.toast(f"Usuário '{user_to_delete}' removido!", icon="🗑️")
+ time.sleep(0.8)
+ st.rerun()
+ else:
+ st.info("Nenhum outro usuário disponível para exclusão.")
+
+ with col_admin_danger:
+ st.markdown("#### Zona Vermelha (Ações Destrutivas)")
+ st.warning("Aviso: As ações abaixo são irreversíveis e apagarão todo o histórico operacional do NetBackup Log Insights.")
+
+ # Form to confirm reset database
+ with st.form("reset_database_form", clear_on_submit=True):
+ confirm_text = st.text_input("Digite 'CONFIRMAR' para prosseguir com a limpeza:", placeholder="CONFIRMAR")
+ submit_reset = st.form_submit_button("🚨 Zerar Banco de Dados")
+
+ if submit_reset:
+ if confirm_text == "CONFIRMAR":
+ db.reset_all_data()
+ st.toast("Banco de dados limpo com sucesso!", icon="🧹")
+ time.sleep(1.0)
+ st.rerun()
+ else:
+ st.error("Texto de confirmação inválido. O banco de dados não foi modificado.")
diff --git a/database.py b/database.py
index 39f96a4..e9a1a86 100644
--- a/database.py
+++ b/database.py
@@ -1,6 +1,7 @@
import os
import sys
import sqlite3
+import hashlib
import pandas as pd
def get_db_path():
@@ -9,12 +10,10 @@ def get_db_path():
If the application is running inside a Docker container, it uses /app/data/nbu_insights.db.
If running locally, it defaults to a local './data' directory.
"""
- # Check if we are running in the container workspace or local workspace
container_data_dir = "/app/data"
if os.path.exists(container_data_dir) or os.environ.get("NBU_INSIGHTS_RUNNING") == "1":
db_dir = container_data_dir
else:
- # Local development fallback
base_dir = os.path.dirname(os.path.abspath(__file__))
db_dir = os.path.join(base_dir, "data")
@@ -31,15 +30,39 @@ def get_connection():
conn.row_factory = sqlite3.Row
return conn
+def hash_password(password, salt=None):
+ """
+ Hashes a password using PBKDF2-SHA256 with 100,000 iterations and a unique salt.
+ Returns string 'salt_hex:hash_hex' which is safe to store in the DB.
+ """
+ if salt is None:
+ salt = os.urandom(16)
+ elif isinstance(salt, str):
+ salt = bytes.fromhex(salt)
+
+ key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
+ return salt.hex() + ":" + key.hex()
+
+def verify_password(stored_password_hash, provided_password):
+ """
+ Verifies a password against its stored PBKDF2-SHA256 hash.
+ """
+ try:
+ salt_hex, key_hex = stored_password_hash.split(":")
+ salt = bytes.fromhex(salt_hex)
+ expected_key_hex = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt, 100000).hex()
+ return key_hex == expected_key_hex
+ except Exception:
+ return False
+
def init_db():
"""
- Initializes the SQLite database tables (processed_files, backup_jobs, and job_actions)
- according to the v2.0 spec.
+ Initializes the SQLite database tables according to v2.0/v3.0 specs.
"""
conn = get_connection()
cursor = conn.cursor()
- # Track processed files
+ # Processed files table
cursor.execute("""
CREATE TABLE IF NOT EXISTS processed_files (
file_hash TEXT PRIMARY KEY,
@@ -48,7 +71,7 @@ def init_db():
);
""")
- # Store backup jobs (with UPSERT mapping)
+ # Backup jobs table
cursor.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
job_id INTEGER PRIMARY KEY,
@@ -67,7 +90,7 @@ def init_db():
);
""")
- # Store technician actions
+ # Technician actions table
cursor.execute("""
CREATE TABLE IF NOT EXISTS job_actions (
job_id INTEGER PRIMARY KEY,
@@ -78,6 +101,15 @@ def init_db():
);
""")
+ # Users table
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ username TEXT PRIMARY KEY,
+ password_hash TEXT,
+ role TEXT DEFAULT 'user' -- 'admin' or 'user'
+ );
+ """)
+
conn.commit()
conn.close()
@@ -108,13 +140,11 @@ def mark_file_processed(file_hash, file_name):
def save_jobs(jobs_df):
"""
Saves or updates jobs into the backup_jobs table using SQL UPSERT.
- Preserves existing job actions by updating job details but not touching actions.
"""
conn = get_connection()
cursor = conn.cursor()
for _, row in jobs_df.iterrows():
- # Handle nan values for start/end times
start_time = str(row['Start Time']) if not pd.isna(row['Start Time']) else None
finish_time = str(row['Finish Time']) if not pd.isna(row['Finish Time']) else None
@@ -186,3 +216,83 @@ def get_historical_jobs():
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
+
+# --- User Management CRUD Methods ---
+
+def create_user(username, password, role='user'):
+ """
+ Hashes the password and creates a new user inside the SQLite store.
+ Username is stored in lowercase to ensure uniqueness.
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+ password_hash = hash_password(password)
+ try:
+ cursor.execute(
+ "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
+ (username.lower().strip(), password_hash, role)
+ )
+ conn.commit()
+ success = True
+ except sqlite3.IntegrityError:
+ # Username collision
+ success = False
+ conn.close()
+ return success
+
+def get_user(username):
+ """
+ Retrieves user profile dictionary by username.
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT * FROM users WHERE username = ?", (username.lower().strip(),))
+ row = cursor.fetchone()
+ conn.close()
+ return dict(row) if row else None
+
+def has_admin_user():
+ """
+ Returns True if there is at least one administrator user registered.
+ Used for routing to the initial bootstrap setup screen.
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1")
+ row = cursor.fetchone()
+ conn.close()
+ return row is not None
+
+def get_all_users():
+ """
+ Returns lists of all user details (excluding their hashed passwords).
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT username, role FROM users ORDER BY username")
+ rows = cursor.fetchall()
+ conn.close()
+ return [dict(row) for row in rows]
+
+def delete_user(username):
+ """
+ Removes a user by their username.
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM users WHERE username = ?", (username.lower().strip(),))
+ conn.commit()
+ conn.close()
+
+def reset_all_data():
+ """
+ Destructive helper: Truncates all backup jobs, processed file hashes,
+ and technician actions. User accounts are NOT deleted.
+ """
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM backup_jobs")
+ cursor.execute("DELETE FROM job_actions")
+ cursor.execute("DELETE FROM processed_files")
+ conn.commit()
+ conn.close()