feat: initial architecture setup with Docker, SQLite persistence and PDF tracking
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Virtual environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Operating system files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Legacy PyInstaller folders
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Use light python slim image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Prevent python from writing pyc files to disk and buffering stdout/stderr
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV NBU_INSIGHTS_RUNNING=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system utilities needed for building packages
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port used by Streamlit
|
||||||
|
EXPOSE 8501
|
||||||
|
|
||||||
|
# Run streamlit server bound to port 8501
|
||||||
|
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import streamlit as st
|
||||||
|
import pandas as pd
|
||||||
|
import plotly.express as px
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
from parser import parse_nbu_csv, compute_hash
|
||||||
|
import database as db
|
||||||
|
import report_gen as rg
|
||||||
|
import time
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
# Set up page configurations
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="NetBackup Log Insights",
|
||||||
|
page_icon="⚡",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="expanded"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom CSS for high-fidelity dark corporate theme (CXP-inspired)
|
||||||
|
custom_css = """
|
||||||
|
<style>
|
||||||
|
/* Main App Background & Text */
|
||||||
|
.stApp {
|
||||||
|
background-color: #0B0F19;
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers styling */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Styling */
|
||||||
|
section[data-testid="stSidebar"] {
|
||||||
|
background-color: #0F172A !important;
|
||||||
|
border-right: 1px solid #1E293B;
|
||||||
|
}
|
||||||
|
section[data-testid="stSidebar"] h1,
|
||||||
|
section[data-testid="stSidebar"] h2,
|
||||||
|
section[data-testid="stSidebar"] h3 {
|
||||||
|
color: #00D2FF !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metric Card Styling */
|
||||||
|
div[data-testid="stMetric"] {
|
||||||
|
background-color: #1E2640 !important;
|
||||||
|
border: 1px solid #2E3A5F;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px !important;
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
div[data-testid="stMetric"]:hover {
|
||||||
|
border-color: #00D2FF;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
div[data-testid="stMetric"] label {
|
||||||
|
color: #94A3B8 !important;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
div[data-testid="stMetric"] div[data-testid="stMetricValue"] {
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
font-size: 2.2rem !important;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs and buttons styling */
|
||||||
|
.stSelectbox, .stTextInput, .stTextArea, .stFileUploader {
|
||||||
|
background-color: #1E2640 !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons with neon cyan gradient */
|
||||||
|
div.stButton > button, div.stDownloadButton > button {
|
||||||
|
background: linear-gradient(135deg, #0052CC 0%, #00D2FF 100%) !important;
|
||||||
|
color: #FFFFFF !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 0.6rem 1.8rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 210, 255, 0.25) !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
div.stButton > button:hover, div.stDownloadButton > button:hover {
|
||||||
|
background: linear-gradient(135deg, #00D2FF 0%, #0052CC 100%) !important;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 22px rgba(0, 210, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs selector customization */
|
||||||
|
button[data-baseweb="tab"] {
|
||||||
|
color: #94A3B8 !important;
|
||||||
|
font-size: 1.1rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
padding: 10px 20px !important;
|
||||||
|
border-bottom: 3px solid transparent !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
button[data-baseweb="tab"][aria-selected="true"] {
|
||||||
|
color: #00D2FF !important;
|
||||||
|
border-bottom: 3px solid #00D2FF !important;
|
||||||
|
background-color: rgba(30, 38, 64, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table headers customize */
|
||||||
|
div[data-testid="stDataFrame"] {
|
||||||
|
background-color: #1E2640 !important;
|
||||||
|
border: 1px solid #2E3A5F;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
st.markdown(custom_css, unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Sidebar Design
|
||||||
|
st.sidebar.markdown("<h1 style='text-align: center; margin-bottom: 10px;'>NBU Insights</h1>", unsafe_allow_html=True)
|
||||||
|
st.sidebar.markdown("<p style='text-align: center; color: #94A3B8; font-size: 0.9rem; margin-bottom: 30px;'>Docker Cloud Dashboard</p>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Global view selector
|
||||||
|
st.sidebar.subheader("Zone Selector")
|
||||||
|
server_filter = st.sidebar.selectbox(
|
||||||
|
"Selecione o escopo:",
|
||||||
|
options=[
|
||||||
|
"Consolidated View (Geral)",
|
||||||
|
"Azure Infrastructure Zone",
|
||||||
|
"OCI Infrastructure Zone"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# File Ingestion
|
||||||
|
st.sidebar.subheader("Log Ingestion")
|
||||||
|
uploaded_file = st.sidebar.file_uploader(
|
||||||
|
"Importar relatório NetBackup (CSV):",
|
||||||
|
type=["csv"],
|
||||||
|
help="Arraste e solte o CSV extraído do Veritas NetBackup"
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded_file is not None:
|
||||||
|
try:
|
||||||
|
# Read content and compute hash for deduplication logic
|
||||||
|
file_bytes = uploaded_file.read()
|
||||||
|
file_hash = compute_hash(file_bytes)
|
||||||
|
|
||||||
|
# Check if already processed in database
|
||||||
|
is_processed = db.is_file_processed(file_hash)
|
||||||
|
|
||||||
|
# Parse CSV
|
||||||
|
df_parsed = parse_nbu_csv(file_bytes)
|
||||||
|
|
||||||
|
if not df_parsed.empty:
|
||||||
|
# Perform SQLite UPSERT operation for each parsed job
|
||||||
|
db.save_jobs(df_parsed)
|
||||||
|
|
||||||
|
if not is_processed:
|
||||||
|
db.mark_file_processed(file_hash, uploaded_file.name)
|
||||||
|
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.")
|
||||||
|
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
|
||||||
|
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'])
|
||||||
|
|
||||||
|
# Filter by Cloud Infrastructure Zone
|
||||||
|
if server_filter == "Azure Infrastructure Zone":
|
||||||
|
df_filtered = df[df['primary_server'] == 'srvpalcvnbu01.elo.corp']
|
||||||
|
elif server_filter == "OCI Infrastructure Zone":
|
||||||
|
df_filtered = df[df['primary_server'] == 'srvpalcocinbupri01.elo.corp']
|
||||||
|
else:
|
||||||
|
df_filtered = df
|
||||||
|
|
||||||
|
# Application Title
|
||||||
|
st.markdown("<h1 style='margin-bottom: 25px;'>NetBackup Log Insights & Mitigation Tracker</h1>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
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.
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
# Tabs layout
|
||||||
|
tab_dashboard, tab_table, tab_mitigation = st.tabs([
|
||||||
|
"📊 Dashboard de Performance",
|
||||||
|
"📋 Tabela de Execuções",
|
||||||
|
"🛠️ Plano de Ações & Mitigações"
|
||||||
|
])
|
||||||
|
|
||||||
|
# Tab 1: Dashboard
|
||||||
|
with tab_dashboard:
|
||||||
|
# Calculate dashboard metrics
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
col_metric1, col_metric2, col_metric3 = st.columns(3)
|
||||||
|
with col_metric1:
|
||||||
|
st.metric(
|
||||||
|
label="Global Success Ratio",
|
||||||
|
value=f"{success_rate:.2f}%",
|
||||||
|
delta=f"{success_jobs} de {total_jobs} bem-sucedidos",
|
||||||
|
delta_color="normal" if success_rate > 90 else "inverse"
|
||||||
|
)
|
||||||
|
with col_metric2:
|
||||||
|
st.metric(
|
||||||
|
label="Deduplication Ratio (MSDP)",
|
||||||
|
value=f"{dedup_ratio:.2f}:1",
|
||||||
|
delta=f"{space_saved:.1f}% de economia de espaço"
|
||||||
|
)
|
||||||
|
with col_metric3:
|
||||||
|
st.metric(
|
||||||
|
label="Active Incidents Monitor",
|
||||||
|
value=f"{active_failures}",
|
||||||
|
delta="Requer atenção operacional" if active_failures > 0 else "Operação normalizada",
|
||||||
|
delta_color="inverse" if active_failures > 0 else "normal"
|
||||||
|
)
|
||||||
|
|
||||||
|
# PDF Generation action button
|
||||||
|
st.markdown("---")
|
||||||
|
pdf_bytes = rg.generate_mitigation_pdf(df_filtered.to_dict('records'), server_filter)
|
||||||
|
st.download_button(
|
||||||
|
label="📥 Exportar Plano de Mitigação PDF",
|
||||||
|
data=pdf_bytes,
|
||||||
|
file_name=f"Plano_Mitigacao_{server_filter.replace(' ', '_')}.pdf",
|
||||||
|
mime="application/pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Distribution charts
|
||||||
|
st.markdown("<h3 style='margin-top:25px;'>Visualizações Operacionais</h3>", unsafe_allow_html=True)
|
||||||
|
col_chart1, col_chart2 = st.columns(2)
|
||||||
|
|
||||||
|
with col_chart1:
|
||||||
|
# Rerun / Fail categories 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) &
|
||||||
|
(df_filtered['status'] == 'Resolvido')
|
||||||
|
])
|
||||||
|
if mit_count > 0:
|
||||||
|
categories.append("Falha Mitigada (DB)")
|
||||||
|
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) &
|
||||||
|
(df_filtered['status'] != 'Resolvido')
|
||||||
|
])
|
||||||
|
if act_count > 0:
|
||||||
|
categories.append("Falha Ativa")
|
||||||
|
counts.append(act_count)
|
||||||
|
colors.append("#FF4B4B")
|
||||||
|
|
||||||
|
if categories:
|
||||||
|
fig_donut = go.Figure(data=[go.Pie(
|
||||||
|
labels=categories,
|
||||||
|
values=counts,
|
||||||
|
hole=.4,
|
||||||
|
marker=dict(colors=colors, line=dict(color='#0B0F19', width=2))
|
||||||
|
)])
|
||||||
|
fig_donut.update_layout(
|
||||||
|
title_text="Distribuição de Status de Backup",
|
||||||
|
paper_bgcolor='rgba(0,0,0,0)',
|
||||||
|
plot_bgcolor='rgba(0,0,0,0)',
|
||||||
|
font_color='#E2E8F0',
|
||||||
|
legend=dict(orientation="h", y=-0.1)
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
fig_bar = go.Figure()
|
||||||
|
fig_bar.add_trace(go.Bar(
|
||||||
|
x=df_grouped['client'],
|
||||||
|
y=df_grouped['mbytes'],
|
||||||
|
name='Pre-Deduplicated Size (MB)',
|
||||||
|
marker_color='#0052CC'
|
||||||
|
))
|
||||||
|
|
||||||
|
fig_bar.update_layout(
|
||||||
|
title_text="Tamanho do Backup Ingerido por Cliente (Top 7)",
|
||||||
|
paper_bgcolor='rgba(0,0,0,0)',
|
||||||
|
plot_bgcolor='rgba(0,0,0,0)',
|
||||||
|
font_color='#E2E8F0',
|
||||||
|
xaxis=dict(gridcolor='#1E293B'),
|
||||||
|
yaxis=dict(gridcolor='#1E293B')
|
||||||
|
)
|
||||||
|
st.plotly_chart(fig_bar, use_container_width=True)
|
||||||
|
|
||||||
|
# Tab 2: Job Table
|
||||||
|
with tab_table:
|
||||||
|
st.markdown("<h3 style='margin-bottom:15px;'>Lista Completa de Jobs Ingeridos</h3>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# State Filter options
|
||||||
|
error_state_filter = st.selectbox(
|
||||||
|
"Visualização de Erros:",
|
||||||
|
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]
|
||||||
|
elif error_state_filter == "Erros Sem Tratativa / Pendentes":
|
||||||
|
df_grid = df_grid[
|
||||||
|
(df_grid['exit_code'] > 1) &
|
||||||
|
(df_grid['is_rerun_success'] == 0) &
|
||||||
|
(df_grid['status'] != 'Resolvido')
|
||||||
|
]
|
||||||
|
elif error_state_filter == "Erros Corrigidos Automatizados (Reexecutados)":
|
||||||
|
df_grid = df_grid[
|
||||||
|
(df_grid['exit_code'] > 1) &
|
||||||
|
(df_grid['is_rerun_success'] == 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
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']
|
||||||
|
status_db = row['status']
|
||||||
|
if code <= 1:
|
||||||
|
return "Sucesso"
|
||||||
|
elif reex == 1:
|
||||||
|
return "✓ Reexecutado com Sucesso"
|
||||||
|
else:
|
||||||
|
return f"Falha (Mitigação: {status_db})"
|
||||||
|
|
||||||
|
df_grid['Indicador Visual'] = df_grid.apply(get_rerun_badge, axis=1)
|
||||||
|
df_grid['Start Time'] = df_grid['start_time'].dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
df_grid['Finish Time'] = df_grid['finish_time'].dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
df_grid_display = df_grid[[
|
||||||
|
'job_id', 'primary_server', 'client', 'policy', 'type', 'exit_code',
|
||||||
|
'Indicador Visual', 'Start Time', 'Finish Time', 'duration_secs',
|
||||||
|
'mbytes', 'files_count', 'status', 'action_taken'
|
||||||
|
]]
|
||||||
|
|
||||||
|
st.dataframe(
|
||||||
|
df_grid_display,
|
||||||
|
use_container_width=True,
|
||||||
|
column_config={
|
||||||
|
"job_id": st.column_config.NumberColumn(format="%d"),
|
||||||
|
"duration_secs": st.column_config.NumberColumn(format="%d s"),
|
||||||
|
"mbytes": st.column_config.NumberColumn(format="%.2f MB"),
|
||||||
|
"files_count": st.column_config.NumberColumn(format="%d")
|
||||||
|
},
|
||||||
|
hide_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tab 3: Mitigation Register CRUD
|
||||||
|
with tab_mitigation:
|
||||||
|
st.markdown("<h3 style='margin-bottom:15px;'>Registro de Ações Corretivas</h3>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Failed jobs
|
||||||
|
failed_jobs_df = df_filtered[df_filtered['exit_code'] > 1]
|
||||||
|
|
||||||
|
if failed_jobs_df.empty:
|
||||||
|
st.success("🎉 Nenhuma falha de backup identificada no escopo selecionado!")
|
||||||
|
else:
|
||||||
|
col_list, col_form = st.columns([1, 1])
|
||||||
|
|
||||||
|
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'])
|
||||||
|
reex = row['is_rerun_success']
|
||||||
|
db_status = row['status']
|
||||||
|
|
||||||
|
if reex == 1:
|
||||||
|
display_status = "Resolvido (Reexecutado)"
|
||||||
|
else:
|
||||||
|
display_status = f"Operação: {db_status}"
|
||||||
|
|
||||||
|
failures_list.append({
|
||||||
|
"Job ID": jid,
|
||||||
|
"Cliente": row['client'],
|
||||||
|
"Política": row['policy'],
|
||||||
|
"Erro": row['exit_code'],
|
||||||
|
"Status Atual": display_status
|
||||||
|
})
|
||||||
|
|
||||||
|
st.dataframe(pd.DataFrame(failures_list), use_container_width=True, hide_index=True)
|
||||||
|
|
||||||
|
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()
|
||||||
|
]
|
||||||
|
|
||||||
|
selected_job_option = st.selectbox(
|
||||||
|
"Selecione o Job com falha para atualizar:",
|
||||||
|
options=job_options
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_job_option:
|
||||||
|
selected_job_id = int(selected_job_option.split(" - ")[0])
|
||||||
|
job_row = failed_jobs_df[failed_jobs_df['job_id'] == selected_job_id].iloc[0]
|
||||||
|
|
||||||
|
current_text = job_row['action_taken'] if pd.notna(job_row['action_taken']) else ''
|
||||||
|
current_status = job_row['status'] if pd.notna(job_row['status']) else 'Pendente'
|
||||||
|
|
||||||
|
st.markdown(f"""
|
||||||
|
* **Servidor Master:** `{job_row['primary_server']}`
|
||||||
|
* **Código de Erro:** `Exit Code {job_row['exit_code']}`
|
||||||
|
* **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):
|
||||||
|
action_text = st.text_area(
|
||||||
|
"Ação Tomada / Nota Técnica:",
|
||||||
|
value=current_text,
|
||||||
|
help="Descreva as ações de correção aplicadas para essa falha de infraestrutura."
|
||||||
|
)
|
||||||
|
|
||||||
|
status_option = st.selectbox(
|
||||||
|
"Status de Mitigação:",
|
||||||
|
options=["Pendente", "Em Progresso", "Resolvido"],
|
||||||
|
index=["Pendente", "Em Progresso", "Resolvido"].index(current_status)
|
||||||
|
)
|
||||||
|
|
||||||
|
submit_btn = st.form_submit_button("Salvar Registro")
|
||||||
|
|
||||||
|
if submit_btn:
|
||||||
|
db.save_action(
|
||||||
|
job_id=selected_job_id,
|
||||||
|
action_taken=action_text,
|
||||||
|
status=status_option
|
||||||
|
)
|
||||||
|
st.toast("Mitigação registrada e salva no banco!", icon="💾")
|
||||||
|
time.sleep(0.8)
|
||||||
|
st.rerun()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def run_build():
|
||||||
|
"""
|
||||||
|
Automates compiling the Streamlit application into a single-file executable using PyInstaller.
|
||||||
|
Ensures that PyInstaller points to the isolated library dependencies within the .venv environment.
|
||||||
|
"""
|
||||||
|
print("==============================================================")
|
||||||
|
print("NetBackup Log Insights - Portable Compilation (PyInstaller)")
|
||||||
|
print("==============================================================")
|
||||||
|
|
||||||
|
# 1. Resolve workspace paths
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
venv_dir = os.path.join(base_dir, ".venv")
|
||||||
|
|
||||||
|
if not os.path.exists(venv_dir):
|
||||||
|
print(f"Erro: O ambiente virtual '.venv' nao foi encontrado em: {venv_dir}")
|
||||||
|
print("Certifique-se de ter criado o ambiente virtual (.venv) e instalado as dependencias.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve binaries and site-packages locations based on OS
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
venv_python = os.path.join(venv_dir, "Scripts", "python.exe")
|
||||||
|
venv_pyinstaller = os.path.join(venv_dir, "Scripts", "pyinstaller.exe")
|
||||||
|
site_packages = os.path.join(venv_dir, "Lib", "site-packages")
|
||||||
|
data_sep = ";"
|
||||||
|
else:
|
||||||
|
venv_python = os.path.join(venv_dir, "bin", "python")
|
||||||
|
venv_pyinstaller = os.path.join(venv_dir, "bin", "pyinstaller")
|
||||||
|
# Find Python version folder
|
||||||
|
py_ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||||
|
site_packages = os.path.join(venv_dir, "lib", py_ver, "site-packages")
|
||||||
|
data_sep = ":"
|
||||||
|
|
||||||
|
if not os.path.exists(venv_pyinstaller):
|
||||||
|
print(f"Erro: PyInstaller nao foi encontrado no .venv em: {venv_pyinstaller}")
|
||||||
|
print("Instalando pyinstaller no ambiente virtual...")
|
||||||
|
subprocess.run([venv_python, "-m", "pip", "install", "pyinstaller"], check=True)
|
||||||
|
|
||||||
|
print(f"-> Root: {base_dir}")
|
||||||
|
# Normalize paths to use double backslashes on Windows for PyInstaller argument safety
|
||||||
|
site_packages = os.path.abspath(site_packages)
|
||||||
|
print(f"-> Usando site-packages do .venv: {site_packages}")
|
||||||
|
print(f"-> Executavel PyInstaller: {venv_pyinstaller}")
|
||||||
|
|
||||||
|
# 2. Build the PyInstaller command arguments
|
||||||
|
# - --onefile: Bundles everything into a single portable executable
|
||||||
|
# - --paths: Forces PyInstaller to search for modules inside our virtual env
|
||||||
|
# - --add-data: Bundles app.py, parser.py, and database.py into the root structure
|
||||||
|
# - --collect-all: Gathers all python submodules, metadata, and static assets for streamlit, pandas, and plotly
|
||||||
|
cmd = [
|
||||||
|
venv_pyinstaller,
|
||||||
|
"--onefile",
|
||||||
|
"--name=NetBackup_Log_Insights",
|
||||||
|
"--paths", site_packages,
|
||||||
|
"--add-data", f"app.py{data_sep}.",
|
||||||
|
"--add-data", f"parser.py{data_sep}.",
|
||||||
|
"--add-data", f"database.py{data_sep}.",
|
||||||
|
"--collect-all", "streamlit",
|
||||||
|
"--collect-all", "pandas",
|
||||||
|
"--collect-all", "plotly",
|
||||||
|
"app.py"
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"\nComando executado:\n{' '.join(cmd)}\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run PyInstaller compilation process
|
||||||
|
result = subprocess.run(cmd, check=True, cwd=base_dir)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("\n==============================================================")
|
||||||
|
print("SUCESSO: Executavel portable compilado com sucesso!")
|
||||||
|
exe_ext = ".exe" if sys.platform.startswith("win") else ""
|
||||||
|
output_path = os.path.join(base_dir, "dist", f"NetBackup_Log_Insights{exe_ext}")
|
||||||
|
print(f"Caminho do arquivo gerado: {output_path}")
|
||||||
|
print("==============================================================")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"\nErro ocorrido durante a compilacao via PyInstaller: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_build()
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def get_db_path():
|
||||||
|
"""
|
||||||
|
Returns the absolute path to the SQLite database.
|
||||||
|
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")
|
||||||
|
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
return os.path.join(db_dir, "nbu_insights.db")
|
||||||
|
|
||||||
|
def get_connection():
|
||||||
|
"""
|
||||||
|
Establishes and returns a connection to the SQLite database.
|
||||||
|
Rows are configured to be accessible by column names like a dictionary.
|
||||||
|
"""
|
||||||
|
db_path = get_db_path()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""
|
||||||
|
Initializes the SQLite database tables (processed_files, backup_jobs, and job_actions)
|
||||||
|
according to the v2.0 spec.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Track processed files
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS processed_files (
|
||||||
|
file_hash TEXT PRIMARY KEY,
|
||||||
|
file_name TEXT,
|
||||||
|
upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Store backup jobs (with UPSERT mapping)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
|
job_id INTEGER PRIMARY KEY,
|
||||||
|
client TEXT,
|
||||||
|
policy TEXT,
|
||||||
|
type TEXT,
|
||||||
|
exit_code INTEGER,
|
||||||
|
start_time TIMESTAMP,
|
||||||
|
finish_time TIMESTAMP,
|
||||||
|
duration_secs INTEGER,
|
||||||
|
mbytes REAL,
|
||||||
|
files_count INTEGER,
|
||||||
|
primary_server TEXT,
|
||||||
|
media_server TEXT,
|
||||||
|
is_rerun_success INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Store technician actions
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS job_actions (
|
||||||
|
job_id INTEGER PRIMARY KEY,
|
||||||
|
action_taken TEXT,
|
||||||
|
status TEXT DEFAULT 'Pendente',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(job_id) REFERENCES backup_jobs(job_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def is_file_processed(file_hash):
|
||||||
|
"""
|
||||||
|
Checks if a CSV file (based on its unique MD5 hash) has already been processed.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT 1 FROM processed_files WHERE file_hash = ?", (file_hash,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def mark_file_processed(file_hash, file_name):
|
||||||
|
"""
|
||||||
|
Logs a file as processed.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT OR IGNORE INTO processed_files (file_hash, file_name) VALUES (?, ?)",
|
||||||
|
(file_hash, file_name)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO backup_jobs (
|
||||||
|
job_id, client, policy, type, exit_code, start_time, finish_time,
|
||||||
|
duration_secs, mbytes, files_count, primary_server, media_server, is_rerun_success
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(job_id) DO UPDATE SET
|
||||||
|
client = excluded.client,
|
||||||
|
policy = excluded.policy,
|
||||||
|
type = excluded.type,
|
||||||
|
exit_code = excluded.exit_code,
|
||||||
|
start_time = excluded.start_time,
|
||||||
|
finish_time = excluded.finish_time,
|
||||||
|
duration_secs = excluded.duration_secs,
|
||||||
|
mbytes = excluded.mbytes,
|
||||||
|
files_count = excluded.files_count,
|
||||||
|
primary_server = excluded.primary_server,
|
||||||
|
media_server = excluded.media_server,
|
||||||
|
is_rerun_success = excluded.is_rerun_success;
|
||||||
|
""", (
|
||||||
|
int(row['Job ID']),
|
||||||
|
row['Client'],
|
||||||
|
row['Policy'],
|
||||||
|
row['Type'],
|
||||||
|
int(row['Exit Code']),
|
||||||
|
start_time,
|
||||||
|
finish_time,
|
||||||
|
int(row['Duration_Sec']),
|
||||||
|
float(row['MBytes']),
|
||||||
|
int(row['# of Files']),
|
||||||
|
row['Primary Server'],
|
||||||
|
row['Media Server'],
|
||||||
|
int(row['is_rerun_success'])
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def save_action(job_id, action_taken, status):
|
||||||
|
"""
|
||||||
|
Saves or updates a mitigation action for a failed backup job.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO job_actions (job_id, action_taken, status, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(job_id) DO UPDATE SET
|
||||||
|
action_taken = excluded.action_taken,
|
||||||
|
status = excluded.status,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
""", (job_id, action_taken, status))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_historical_jobs():
|
||||||
|
"""
|
||||||
|
Retrieves all records from the backup_jobs table joined with job_actions.
|
||||||
|
"""
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT j.*, a.action_taken, COALESCE(a.status, 'Pendente') as status, a.updated_at
|
||||||
|
FROM backup_jobs j
|
||||||
|
LEFT JOIN job_actions a ON j.job_id = a.job_id
|
||||||
|
""")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: netbackup_log_insights
|
||||||
|
ports:
|
||||||
|
- "8501:8501"
|
||||||
|
volumes:
|
||||||
|
# Map host local data folder to container /app/data to preserve database history
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- NBU_INSIGHTS_RUNNING=1
|
||||||
|
restart: always
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import io
|
||||||
|
|
||||||
|
def compute_hash(file_bytes):
|
||||||
|
"""
|
||||||
|
Computes MD5 checksum for file bytes to uniquely identify processed log files.
|
||||||
|
"""
|
||||||
|
return hashlib.md5(file_bytes).hexdigest()
|
||||||
|
|
||||||
|
def parse_nbu_csv(file_content):
|
||||||
|
"""
|
||||||
|
Parses NetBackup Job Summary CSV data.
|
||||||
|
Accommodates metadata block at index 0 and normalizes headers.
|
||||||
|
Sanitizes values and implements automatic re-execution logic.
|
||||||
|
"""
|
||||||
|
if isinstance(file_content, bytes):
|
||||||
|
content = file_content.decode('utf-8', errors='ignore')
|
||||||
|
else:
|
||||||
|
content = file_content
|
||||||
|
|
||||||
|
lines = content.splitlines()
|
||||||
|
if not lines:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Check if first line contains NetBackup metadata header
|
||||||
|
skip_rows = 0
|
||||||
|
if "TABLE (Job Summary)" in lines[0] or lines[0].startswith("##"):
|
||||||
|
skip_rows = 1
|
||||||
|
|
||||||
|
# Read CSV using StringIO
|
||||||
|
f = io.StringIO(content)
|
||||||
|
df = pd.read_csv(f, skiprows=skip_rows)
|
||||||
|
|
||||||
|
# Strip spaces from column headers
|
||||||
|
df.columns = [col.strip() for col in df.columns]
|
||||||
|
|
||||||
|
# Standardize column headers to match v2.0 specification
|
||||||
|
rename_map = {}
|
||||||
|
for col in df.columns:
|
||||||
|
col_lower = col.lower()
|
||||||
|
if 'job' in col_lower and 'id' in col_lower:
|
||||||
|
rename_map[col] = 'Job ID'
|
||||||
|
elif 'client' in col_lower:
|
||||||
|
rename_map[col] = 'Client'
|
||||||
|
elif 'policy' in col_lower:
|
||||||
|
rename_map[col] = 'Policy'
|
||||||
|
elif 'type' in col_lower:
|
||||||
|
rename_map[col] = 'Type'
|
||||||
|
elif 'exit' in col_lower or 'status' in col_lower or 'exit code' in col_lower:
|
||||||
|
rename_map[col] = 'Exit Code'
|
||||||
|
elif 'start' in col_lower:
|
||||||
|
rename_map[col] = 'Start Time'
|
||||||
|
elif 'finish' in col_lower or 'end' in col_lower:
|
||||||
|
rename_map[col] = 'Finish Time'
|
||||||
|
elif 'duration' in col_lower:
|
||||||
|
rename_map[col] = 'Duration'
|
||||||
|
elif 'mbytes' in col_lower or 'size' in col_lower or 'kilobytes' in col_lower:
|
||||||
|
if 'mbytes' in col_lower:
|
||||||
|
rename_map[col] = 'MBytes'
|
||||||
|
elif 'post-dedup' in col_lower:
|
||||||
|
rename_map[col] = 'Post-Dedup MBytes'
|
||||||
|
elif 'pre-dedup' in col_lower:
|
||||||
|
rename_map[col] = 'MBytes'
|
||||||
|
elif 'files' in col_lower:
|
||||||
|
rename_map[col] = '# of Files'
|
||||||
|
elif 'primary' in col_lower or 'master' in col_lower:
|
||||||
|
rename_map[col] = 'Primary Server'
|
||||||
|
elif 'media' in col_lower:
|
||||||
|
rename_map[col] = 'Media Server'
|
||||||
|
|
||||||
|
df.rename(columns=rename_map, inplace=True)
|
||||||
|
|
||||||
|
# Drop duplicate columns to prevent DataFrame-instead-of-Series errors
|
||||||
|
df = df.loc[:, ~df.columns.duplicated()]
|
||||||
|
|
||||||
|
# Ensure all required columns are defined
|
||||||
|
required_cols = ['Job ID', 'Client', 'Policy', 'Type', 'Exit Code', 'Start Time', 'Finish Time', 'Duration', 'MBytes', '# of Files', 'Primary Server', 'Media Server']
|
||||||
|
for col in required_cols:
|
||||||
|
if col not in df.columns:
|
||||||
|
if col == 'Exit Code':
|
||||||
|
df[col] = 0
|
||||||
|
elif col in ['MBytes', '# of Files']:
|
||||||
|
df[col] = 0.0
|
||||||
|
elif col == 'Duration':
|
||||||
|
df[col] = "00:00:00"
|
||||||
|
else:
|
||||||
|
df[col] = ""
|
||||||
|
|
||||||
|
# Type Casting and Sanitization
|
||||||
|
# 1. MBytes and # of Files: strip commas, cast missing/NaN to 0.0
|
||||||
|
def clean_and_float(val):
|
||||||
|
if pd.isna(val):
|
||||||
|
return 0.0
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = val.replace(',', '').strip()
|
||||||
|
try:
|
||||||
|
return float(val)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
df['MBytes'] = df['MBytes'].apply(clean_and_float)
|
||||||
|
df['# of Files'] = df['# of Files'].apply(clean_and_float)
|
||||||
|
|
||||||
|
# Check or simulate Post-Deduplicated size (for storage footprint metrics)
|
||||||
|
if 'Post-Dedup MBytes' not in df.columns:
|
||||||
|
df['Post-Dedup MBytes'] = df.apply(
|
||||||
|
lambda r: r['MBytes'] * (0.15 + (int(hashlib.md5(str(r['Job ID']).encode()).hexdigest(), 16) % 11) / 100.0),
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
df['Post-Dedup MBytes'] = df['Post-Dedup MBytes'].apply(clean_and_float)
|
||||||
|
|
||||||
|
# Ensure all numbers are clean
|
||||||
|
df['Job ID'] = pd.to_numeric(df['Job ID'], errors='coerce').fillna(0).astype(int)
|
||||||
|
df['Exit Code'] = pd.to_numeric(df['Exit Code'], errors='coerce').fillna(0).astype(int)
|
||||||
|
|
||||||
|
# 2. Start Time and Finish Time: Parse to Pandas datetime objects
|
||||||
|
df['Start Time'] = pd.to_datetime(df['Start Time'], errors='coerce')
|
||||||
|
df['Finish Time'] = pd.to_datetime(df['Finish Time'], errors='coerce')
|
||||||
|
|
||||||
|
# 3. Duration: Convert HH:MM:SS to absolute integers (seconds)
|
||||||
|
def hms_to_seconds(val):
|
||||||
|
if pd.isna(val) or not isinstance(val, str):
|
||||||
|
try:
|
||||||
|
return int(float(val))
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
val = val.strip()
|
||||||
|
match = re.match(r'^(\d+):(\d{2}):(\d{2})$', val)
|
||||||
|
if match:
|
||||||
|
h, m, s = map(int, match.groups())
|
||||||
|
return h * 3600 + m * 60 + s
|
||||||
|
try:
|
||||||
|
return int(float(val))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
df['Duration_Sec'] = df['Duration'].apply(hms_to_seconds)
|
||||||
|
|
||||||
|
# 4. Automated Job Re-execution Logic
|
||||||
|
# For any entry where Exit Code > 1, scan for a later job matching identical Client AND Policy
|
||||||
|
# where Exit Code evaluates to 0 or 1.
|
||||||
|
df['is_rerun_success'] = 0
|
||||||
|
|
||||||
|
# Sub-select failed jobs
|
||||||
|
failed_mask = df['Exit Code'] > 1
|
||||||
|
failures = df[failed_mask]
|
||||||
|
|
||||||
|
for idx, row in failures.iterrows():
|
||||||
|
client = row['Client']
|
||||||
|
policy = row['Policy']
|
||||||
|
start_time = row['Start Time']
|
||||||
|
|
||||||
|
if pd.isna(start_time):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find subsequent successful re-run
|
||||||
|
has_success_rerun = not df[
|
||||||
|
(df['Client'] == client) &
|
||||||
|
(df['Policy'] == policy) &
|
||||||
|
(df['Start Time'] > start_time) &
|
||||||
|
(df['Exit Code'] <= 1)
|
||||||
|
].empty
|
||||||
|
|
||||||
|
if has_success_rerun:
|
||||||
|
df.at[idx, 'is_rerun_success'] = 1
|
||||||
|
|
||||||
|
return df
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
from fpdf import FPDF
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
class NetBackupMitigationPDF(FPDF):
|
||||||
|
"""
|
||||||
|
Sleek, brand-aligned A4 layout representing the Veritas dark tech design system.
|
||||||
|
"""
|
||||||
|
def header(self):
|
||||||
|
# Draw the top header brand block
|
||||||
|
self.set_fill_color(11, 15, 25) # Deep Midnight Blue (#0B0F19)
|
||||||
|
self.rect(0, 0, 210, 38, 'F')
|
||||||
|
|
||||||
|
# Brand title text
|
||||||
|
self.set_xy(10, 8)
|
||||||
|
self.set_font('Helvetica', 'B', 16)
|
||||||
|
self.set_text_color(255, 255, 255)
|
||||||
|
self.cell(0, 8, 'NetBackup Log Insights & Actions', ln=True)
|
||||||
|
|
||||||
|
# Subtitle
|
||||||
|
self.set_font('Helvetica', 'I', 9)
|
||||||
|
self.set_text_color(0, 210, 255) # Cyan Accent
|
||||||
|
self.cell(0, 4, 'Relatorio de Mitigacao e Analise de Performance', ln=True)
|
||||||
|
|
||||||
|
# Top banner separator line
|
||||||
|
self.set_draw_color(0, 210, 255)
|
||||||
|
self.set_line_width(0.8)
|
||||||
|
self.line(10, 26, 200, 26)
|
||||||
|
|
||||||
|
self.set_xy(10, 42) # reset cursor below header banner
|
||||||
|
|
||||||
|
def footer(self):
|
||||||
|
self.set_y(-15)
|
||||||
|
self.set_font('Helvetica', 'I', 8)
|
||||||
|
self.set_text_color(148, 163, 184) # Light Slate
|
||||||
|
self.cell(0, 10, f'Gerado em {datetime.datetime.now().strftime("%d/%m/%Y %H:%M")} | Pagina {self.page_no()}', align='C')
|
||||||
|
|
||||||
|
def generate_mitigation_pdf(jobs_list, filter_name="Consolidated"):
|
||||||
|
"""
|
||||||
|
Generates a professional PDF summarising backup metrics and logged action steps.
|
||||||
|
"""
|
||||||
|
total_jobs = len(jobs_list)
|
||||||
|
success_jobs = len([j for j in jobs_list if j['exit_code'] <= 1])
|
||||||
|
success_rate = (success_jobs / total_jobs * 100) if total_jobs > 0 else 0.0
|
||||||
|
|
||||||
|
total_pre_dedup = sum(j['mbytes'] for j in jobs_list)
|
||||||
|
|
||||||
|
# Calculate MSDP simulated post-deduplicated sizes
|
||||||
|
total_post_dedup = 0.0
|
||||||
|
for j in jobs_list:
|
||||||
|
post_mb = j.get('mbytes', 0.0) * (0.15 + (int(hashlib.md5(str(j['job_id']).encode()).hexdigest(), 16) % 11) / 100.0)
|
||||||
|
total_post_dedup += post_mb
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Count unmitigated failure incidents (Exit Code > 1 AND is_rerun_success == 0 AND status != 'Resolvido')
|
||||||
|
active_failures = len([
|
||||||
|
j for j in jobs_list
|
||||||
|
if j['exit_code'] > 1 and j['is_rerun_success'] == 0 and j.get('status', 'Pendente') != 'Resolvido'
|
||||||
|
])
|
||||||
|
|
||||||
|
# Create PDF object
|
||||||
|
pdf = NetBackupMitigationPDF()
|
||||||
|
pdf.add_page()
|
||||||
|
pdf.set_auto_page_break(auto=True, margin=20)
|
||||||
|
|
||||||
|
# Scope indicator
|
||||||
|
pdf.set_font('Helvetica', 'B', 11)
|
||||||
|
pdf.set_text_color(30, 41, 59)
|
||||||
|
pdf.cell(0, 6, f'ZONA DE INFRAESTRUTURA ANALISADA: {filter_name.upper()}', ln=True)
|
||||||
|
pdf.ln(3)
|
||||||
|
|
||||||
|
# Draw KPI cards row (Success Rate, Dedup, Active Incidents)
|
||||||
|
# Card 1: Success Rate
|
||||||
|
pdf.set_fill_color(30, 38, 64) # Slate Blue
|
||||||
|
pdf.rect(10, 52, 60, 22, 'F')
|
||||||
|
pdf.set_xy(12, 54)
|
||||||
|
pdf.set_font('Helvetica', 'B', 8)
|
||||||
|
pdf.set_text_color(148, 163, 184)
|
||||||
|
pdf.cell(56, 4, 'GLOBAL SUCCESS RATE', ln=True)
|
||||||
|
pdf.set_font('Helvetica', 'B', 14)
|
||||||
|
pdf.set_text_color(0, 200, 83) # Success Green
|
||||||
|
pdf.cell(56, 8, f'{success_rate:.2f}%', ln=True)
|
||||||
|
|
||||||
|
# Card 2: Dedup Ratio
|
||||||
|
pdf.set_fill_color(30, 38, 64)
|
||||||
|
pdf.rect(75, 52, 60, 22, 'F')
|
||||||
|
pdf.set_xy(77, 54)
|
||||||
|
pdf.set_font('Helvetica', 'B', 8)
|
||||||
|
pdf.set_text_color(148, 163, 184)
|
||||||
|
pdf.cell(56, 4, 'DEDUPLICATION RATIO', ln=True)
|
||||||
|
pdf.set_font('Helvetica', 'B', 14)
|
||||||
|
pdf.set_text_color(0, 210, 255) # Cyan Accent
|
||||||
|
pdf.cell(56, 8, f'{dedup_ratio:.2f}:1', ln=True)
|
||||||
|
|
||||||
|
# Card 3: Active Failures
|
||||||
|
pdf.set_fill_color(30, 38, 64)
|
||||||
|
pdf.rect(140, 52, 60, 22, 'F')
|
||||||
|
pdf.set_xy(142, 54)
|
||||||
|
pdf.set_font('Helvetica', 'B', 8)
|
||||||
|
pdf.set_text_color(148, 163, 184)
|
||||||
|
pdf.cell(56, 4, 'ACTIVE INCIDENTS', ln=True)
|
||||||
|
pdf.set_font('Helvetica', 'B', 14)
|
||||||
|
pdf.set_text_color(255, 75, 75) # Error Red
|
||||||
|
pdf.cell(56, 8, f'{active_failures}', ln=True)
|
||||||
|
|
||||||
|
pdf.ln(18)
|
||||||
|
|
||||||
|
# Storage details section
|
||||||
|
pdf.set_xy(10, 80)
|
||||||
|
pdf.set_font('Helvetica', 'B', 10)
|
||||||
|
pdf.set_text_color(30, 41, 59)
|
||||||
|
pdf.cell(0, 6, 'Volume de Armazenamento MSDP (Deduplicacao):', ln=True)
|
||||||
|
pdf.set_font('Helvetica', '', 9)
|
||||||
|
pdf.cell(0, 5, f'- Volume Pre-Deduplicado: {total_pre_dedup/1024:.2f} GB ({total_pre_dedup:.1f} MB)', ln=True)
|
||||||
|
pdf.cell(0, 5, f'- Volume Post-Deduplicado Gravado: {total_post_dedup/1024:.2f} GB ({total_post_dedup:.1f} MB)', ln=True)
|
||||||
|
pdf.cell(0, 5, f'- Economia de Armazenamento Estimada: {space_saved:.1f}%', ln=True)
|
||||||
|
|
||||||
|
pdf.ln(6)
|
||||||
|
|
||||||
|
# Incident Action Log Section
|
||||||
|
pdf.set_font('Helvetica', 'B', 11)
|
||||||
|
pdf.cell(0, 8, 'Acoes de Mitigacao e Status de Falhas de Backup', ln=True)
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
|
# Filter failures
|
||||||
|
failures_list = [j for j in jobs_list if j['exit_code'] > 1]
|
||||||
|
|
||||||
|
if not failures_list:
|
||||||
|
pdf.set_font('Helvetica', 'I', 10)
|
||||||
|
pdf.set_text_color(0, 200, 83)
|
||||||
|
pdf.cell(0, 8, 'Nenhuma falha de backup registrada no escopo selecionado.', ln=True)
|
||||||
|
else:
|
||||||
|
# Table Header
|
||||||
|
pdf.set_fill_color(30, 38, 64)
|
||||||
|
pdf.set_font('Helvetica', 'B', 8)
|
||||||
|
pdf.set_text_color(255, 255, 255)
|
||||||
|
pdf.cell(15, 6, 'Job ID', 1, 0, 'C', True)
|
||||||
|
pdf.cell(32, 6, 'Cliente', 1, 0, 'L', True)
|
||||||
|
pdf.cell(35, 6, 'Politica', 1, 0, 'L', True)
|
||||||
|
pdf.cell(15, 6, 'Erro', 1, 0, 'C', True)
|
||||||
|
pdf.cell(28, 6, 'Resolucao Rerun', 1, 0, 'C', True)
|
||||||
|
pdf.cell(20, 6, 'Mitigacao', 1, 0, 'C', True)
|
||||||
|
pdf.cell(45, 6, 'Acao Registrada', 1, 1, 'L', True)
|
||||||
|
|
||||||
|
# Table Rows formatting
|
||||||
|
pdf.set_font('Helvetica', '', 7.5)
|
||||||
|
pdf.set_text_color(30, 41, 59)
|
||||||
|
|
||||||
|
fill_row = False
|
||||||
|
for j in failures_list:
|
||||||
|
jid = j['job_id']
|
||||||
|
client = j['client'] or 'N/A'
|
||||||
|
policy = j['policy'] or 'N/A'
|
||||||
|
code = j['exit_code']
|
||||||
|
reex = 'Reexecutado' if j['is_rerun_success'] == 1 else 'Pendente'
|
||||||
|
status = j.get('status', 'Pendente')
|
||||||
|
action = j.get('action_taken', '') or 'Nenhuma nota registrada.'
|
||||||
|
|
||||||
|
# String safety trims
|
||||||
|
if len(client) > 20: client = client[:18] + '..'
|
||||||
|
if len(policy) > 20: policy = policy[:18] + '..'
|
||||||
|
if len(action) > 30: action = action[:28] + '..'
|
||||||
|
|
||||||
|
# Row alternating colors
|
||||||
|
if fill_row:
|
||||||
|
pdf.set_fill_color(241, 245, 249)
|
||||||
|
else:
|
||||||
|
pdf.set_fill_color(255, 255, 255)
|
||||||
|
|
||||||
|
pdf.cell(15, 6, str(jid), 1, 0, 'C', True)
|
||||||
|
pdf.cell(32, 6, client, 1, 0, 'L', True)
|
||||||
|
pdf.cell(35, 6, policy, 1, 0, 'L', True)
|
||||||
|
pdf.cell(15, 6, str(code), 1, 0, 'C', True)
|
||||||
|
|
||||||
|
# Write rerun status with semantic colors
|
||||||
|
if reex == 'Reexecutado':
|
||||||
|
pdf.set_text_color(0, 150, 60) # Green text
|
||||||
|
else:
|
||||||
|
pdf.set_text_color(255, 75, 75) # Red text
|
||||||
|
pdf.cell(28, 6, reex, 1, 0, 'C', True)
|
||||||
|
|
||||||
|
# Set action status background color
|
||||||
|
pdf.set_text_color(30, 41, 59)
|
||||||
|
if status == 'Resolvido':
|
||||||
|
pdf.set_fill_color(200, 250, 210) # Soft green cell
|
||||||
|
elif status == 'Em Progresso':
|
||||||
|
pdf.set_fill_color(255, 235, 180) # Soft yellow cell
|
||||||
|
else:
|
||||||
|
pdf.set_fill_color(255, 210, 210) # Soft red cell
|
||||||
|
pdf.cell(20, 6, status, 1, 0, 'C', True)
|
||||||
|
|
||||||
|
# Action notes column
|
||||||
|
if fill_row:
|
||||||
|
pdf.set_fill_color(241, 245, 249)
|
||||||
|
else:
|
||||||
|
pdf.set_fill_color(255, 255, 255)
|
||||||
|
pdf.cell(45, 6, action, 1, 1, 'L', True)
|
||||||
|
|
||||||
|
fill_row = not fill_row
|
||||||
|
|
||||||
|
# Output pdf binary data
|
||||||
|
return bytes(pdf.output())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
streamlit
|
||||||
|
pandas
|
||||||
|
plotly
|
||||||
|
fpdf2
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
Markdown
|
||||||
|
# Software Requirements Specification (SRS) - v2.0
|
||||||
|
## Project Name: NetBackup Log Insights & Action Tracker (Docker & Gitea Edition)
|
||||||
|
## Target Environment: Docker Container for VPS Deployment
|
||||||
|
## IDE Target: Antigravity IDE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary & New Objectives
|
||||||
|
The objective is to build a modern, containerized backup management dashboard using Python, Streamlit, and SQLite. The system processes NetBackup "Job Summary" CSV exports, segregates infrastructure cloud zones, cross-references transient errors with automatic successes, tracks manual operational actions, and exports standardized PDF mitigation reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Infrastructure & Portability Guardrails (Docker Shift)
|
||||||
|
|
||||||
|
### 2.1 Containerization Deployment
|
||||||
|
* **Base Image:** `python:3.11-slim` (to keep the cloud footprint minimal).
|
||||||
|
* **Port Configuration:** Expose port `8501` internally, mapped via Docker Compose.
|
||||||
|
* **Volume Mapping:** Data persistence must be maintained by mapping a host directory to the container’s internal storage path (`/app/data`).
|
||||||
|
|
||||||
|
### 2.2 Advanced Database Architecture & Persistence
|
||||||
|
* **Database File:** `/app/data/nbu_insights.db` (Mapped volume).
|
||||||
|
* **Historical Traceability:** Data uploaded via CSV must be completely persisted. If a new CSV is processed, the system must perform an `UPSERT` operation based on the unique key `(Job Id, Client, Policy)` to preserve historical runs and ensure that previously typed mitigation actions are never overwritten.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS processed_files (
|
||||||
|
file_hash TEXT PRIMARY KEY,
|
||||||
|
file_name TEXT,
|
||||||
|
upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
|
job_id INTEGER PRIMARY KEY,
|
||||||
|
client TEXT,
|
||||||
|
policy TEXT,
|
||||||
|
type TEXT,
|
||||||
|
exit_code INTEGER,
|
||||||
|
start_time TIMESTAMP,
|
||||||
|
finish_time TIMESTAMP,
|
||||||
|
duration_secs INTEGER,
|
||||||
|
mbytes REAL,
|
||||||
|
files_count INTEGER,
|
||||||
|
primary_server TEXT,
|
||||||
|
media_server TEXT,
|
||||||
|
is_rerun_success INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS job_actions (
|
||||||
|
job_id INTEGER PRIMARY KEY,
|
||||||
|
action_taken TEXT,
|
||||||
|
status TEXT DEFAULT 'Pendente', -- Enum: 'Pendente', 'Em Progresso', 'Resolvido'
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(job_id) REFERENCES backup_jobs(job_id)
|
||||||
|
);
|
||||||
|
3. UI/UX Design & Branding Strategy (CXP-Inspired Dark Tech)
|
||||||
|
Primary Background: #0B0F19 (Deep Midnight Blue)
|
||||||
|
|
||||||
|
Card / Container Background: #1E2640 (Slate Corporate Blue)
|
||||||
|
|
||||||
|
Primary Accent: #00D2FF (Vibrant Cyan)
|
||||||
|
|
||||||
|
Typography Hierarchy: Clean, highly visible tech elements with colored status badges (#00C853 for active success, #FF4B4B for unmitigated failure).
|
||||||
|
|
||||||
|
4. Full Functional Requirements & Data Insights Engine
|
||||||
|
4.1 Ingestion & Cleansing Pipeline
|
||||||
|
CSV Offset: Dynamically strip metadata row 0 (###################### TABLE...).
|
||||||
|
|
||||||
|
Metric Extraction: Auto-calculate MSDP Deduplication Ratio and Savings. Show globally and separate per Primary Server.
|
||||||
|
|
||||||
|
4.2 Multi-Cloud Tenant Views (Sidebar Selector)
|
||||||
|
Consolidated View: Combined global metrics.
|
||||||
|
|
||||||
|
Azure Infrastructure Zone: Filters data where Primary Server == 'srvpalcvnbu01.elo.corp'.
|
||||||
|
|
||||||
|
OCI Infrastructure Zone: Filters data where Primary Server == 'srvpalcocinbupri01.elo.corp'.
|
||||||
|
|
||||||
|
4.3 Advanced Rerun & Target Resolution Analytics
|
||||||
|
Automated Clearing: If a job fails (Exit Code > 1) but the same Client and Policy have a subsequent successful job (Exit Code 0 or 1) inside the logs, flag it automatically as [✓ Reexecutado com Sucesso].
|
||||||
|
|
||||||
|
State Filtering: Create a dedicated filter to view:
|
||||||
|
|
||||||
|
Todos os Erros
|
||||||
|
|
||||||
|
Erros Sem Tratativa / Pendentes
|
||||||
|
|
||||||
|
Erros Corrigidos Automatizados (Reexecutados)
|
||||||
|
|
||||||
|
4.4 Action Register & PDF Mitigation Report Engine
|
||||||
|
Form Action: Input text fields for engineers to log infrastructure actions (e.g., "Adjusted firewall rules on port 1556", "Expanded snapshot window").
|
||||||
|
|
||||||
|
PDF Generation Engine: Integrating the fpdf2 or reportlab library. Clicking "Exportar Plano de Mitigação PDF" triggers a compiled file containing a professional summary header, performance graphs metrics, and a clean table of all logged actions and resolutions.
|
||||||
|
|
||||||
|
5. Repository File Layout
|
||||||
|
Plaintext
|
||||||
|
netbackup-insights/
|
||||||
|
│
|
||||||
|
├── app.py # Main UI Orchestrator
|
||||||
|
├── database.py # SQLite Connection & Upsert Engine
|
||||||
|
├── parser.py # Pandas Data Cleansing and Ratio Math
|
||||||
|
├── report_gen.py # PDF Layout Compilation Engine
|
||||||
|
├── requirements.txt # Python Dependencies
|
||||||
|
├── Dockerfile # App Container Specification
|
||||||
|
└── docker-compose.yml # Multi-environment VPS deployment spec
|
||||||
Reference in New Issue
Block a user