add backup statistics screen

This commit is contained in:
rambros 2026-03-12 13:50:47 +05:30
parent 700f8f8ec4
commit 8f80d36503
5 changed files with 490 additions and 22 deletions

View file

@ -100,7 +100,7 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
""" """
stats = {"messages": 0, "threads": 0, "attachments": 0} stats = {"messages": 0, "threads": 0, "attachments": 0}
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True):
if not context.is_running: if not context.is_running:
break break
@ -152,7 +152,7 @@ async def migrate_messages(
logger.info(f"Resuming migration from after message ID: {after_message_id}") logger.info(f"Resuming migration from after message ID: {after_message_id}")
try: try:
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True):
if not context.is_running: if not context.is_running:
logger.warning("Migration interrupted by user (is_running=False)") logger.warning("Migration interrupted by user (is_running=False)")
break break

View file

@ -106,7 +106,7 @@ async def analyze_migration(context: MigrationContext, source_channel_id: int, a
"last_message_url": "" "last_message_url": ""
} }
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True):
if not context.is_running: if not context.is_running:
break break
@ -157,7 +157,7 @@ async def migrate_messages(
logger.info(f"Resuming migration from after message ID: {after_message_id}") logger.info(f"Resuming migration from after message ID: {after_message_id}")
try: try:
async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id): async for msg in context.discord_reader.fetch_message_history(source_channel_id, after_id=after_message_id, inclusive=True):
if not context.is_running: if not context.is_running:
logger.warning("Migration interrupted by user (is_running=False)") logger.warning("Migration interrupted by user (is_running=False)")
break break

View file

@ -76,10 +76,16 @@ class BackupPane(Container):
yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True, tooltip="Backup Discord server roles, emojis, and channel structure") yield Button("Backup Server Profile", id="bp_backup_profile", disabled=True, tooltip="Backup Discord server roles, emojis, and channel structure")
yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True, variant="primary", tooltip="Select and backup message history from text channels") yield Button("Backup Channel Messages", id="bp_backup_msgs", disabled=True, variant="primary", tooltip="Select and backup message history from text channels")
yield Button("Update Existing Backup", id="bp_backup_sync", disabled=True, variant="success", tooltip="Scan for new messages\n& Update existing backup") yield Button("Update Existing Backup", id="bp_backup_sync", disabled=True, variant="success", tooltip="Scan for new messages\n& Update existing backup")
yield Rule(id="bp_backup_stats_rule")
yield Button("Backup Stats", id="bp_backup_stats", variant="warning", flat=True,disabled=True, tooltip="View detailed statistics, storage, and entity metrics for the current backup profile")
def on_mount(self) -> None: def on_mount(self) -> None:
self._validate() self._validate()
def on_show(self) -> None:
"""Re-validate when the pane regains visibility (e.g. after a backup operation finishes)."""
self._validate()
def reload_config(self) -> None: def reload_config(self) -> None:
self.config = load_config(self.config_path) self.config = load_config(self.config_path)
self.engine = MigrationContext(self.config, target_platform=self.config.target_platform or "fluxer", base_dir=f"ReaperFiles-{self.cfg_name}") self.engine = MigrationContext(self.config, target_platform=self.config.target_platform or "fluxer", base_dir=f"ReaperFiles-{self.cfg_name}")
@ -105,28 +111,42 @@ class BackupPane(Container):
backup_text = "" backup_text = ""
info = self._get_backup_info() info = self._get_backup_info()
has_backup = False
if info: if info:
backup_text = f"Last backup: [cyan]{info}[/cyan]" backup_text = f"Last backup: [cyan]{info}[/cyan]"
has_backup = True
self._update_ui(s_text, b_text, backup_text, valid) self._update_ui(s_text, b_text, backup_text, valid, has_backup)
except Exception as e: except Exception as e:
self._update_ui(f"[red]Error: {e}[/red]", "", "", False) self._update_ui(f"[red]Error: {e}[/red]", "", "", False, False)
def _get_backup_info(self) -> str | None: def _get_backup_info(self) -> str | None:
profile_file = Path(f"ReaperFiles-{self.cfg_name}") / "server_profile" / "profile.json" base_dir = Path(f"ReaperFiles-{self.cfg_name}")
if profile_file.exists(): if not base_dir.exists():
try: return None
with open(profile_file, "r", encoding="utf-8") as f:
data = json.load(f) target_dir = None
ts_str = data.get("last_backup") for child in base_dir.iterdir():
if ts_str: if child.is_dir() and (child / "server_profile" / "profile.json").exists():
dt = datetime.fromisoformat(ts_str) target_dir = child
return dt.strftime("%d-%b-%Y %H:%M") break
except Exception:
pass if not target_dir:
return None
profile_file = target_dir / "server_profile" / "profile.json"
try:
with open(profile_file, "r", encoding="utf-8") as f:
data = json.load(f)
ts_str = data.get("last_backup")
if ts_str:
dt = datetime.fromisoformat(ts_str)
return dt.strftime("%d-%b-%Y %H:%M")
except Exception:
pass
return None return None
def _update_ui(self, server_text, bot_text, backup_text, enabled): def _update_ui(self, server_text, bot_text, backup_text, enabled, has_backup: bool = False):
try: try:
self.query_one("#bp_lbl_server", Label).update(f"Server: {server_text}") self.query_one("#bp_lbl_server", Label).update(f"Server: {server_text}")
self.query_one("#bp_lbl_bot", Label).update(f"Source: {bot_text}") self.query_one("#bp_lbl_bot", Label).update(f"Source: {bot_text}")
@ -144,6 +164,10 @@ class BackupPane(Container):
for bid in ("#bp_backup_profile", "#bp_backup_msgs", "#bp_backup_sync"): for bid in ("#bp_backup_profile", "#bp_backup_msgs", "#bp_backup_sync"):
self.query_one(bid, Button).disabled = not enabled self.query_one(bid, Button).disabled = not enabled
self.query_one("#bp_backup_stats", Button).display = has_backup
self.query_one("#bp_backup_stats", Button).disabled = not has_backup
self.query_one("#bp_backup_stats_rule", Rule).display = has_backup
except Exception: except Exception:
pass pass
@ -157,6 +181,9 @@ class BackupPane(Container):
self.run_backup_messages() self.run_backup_messages()
elif bid == "bp_backup_sync": elif bid == "bp_backup_sync":
self.run_backup_sync() self.run_backup_sync()
elif bid == "bp_backup_stats":
from src.ui.backup_stats import BackupStatsScreen
self.app.push_screen(BackupStatsScreen(self.cfg_name))
# ── workers ─────────────────────────────────────────────────────────── # ── workers ───────────────────────────────────────────────────────────
@ -211,6 +238,7 @@ class BackupPane(Container):
modal.phase_report("Profile Backup", "error") modal.phase_report("Profile Backup", "error")
finally: finally:
await self.engine.close_connections() await self.engine.close_connections()
self._validate()
@work(exclusive=True) @work(exclusive=True)
async def run_backup_messages(self) -> None: async def run_backup_messages(self) -> None:
@ -395,6 +423,7 @@ class BackupPane(Container):
modal_prog.phase_report("Message Backup", "error", show_back=False) modal_prog.phase_report("Message Backup", "error", show_back=False)
finally: finally:
await self.engine.close_connections() await self.engine.close_connections()
self._validate()
@work(exclusive=True) @work(exclusive=True)
async def run_backup_sync(self) -> None: async def run_backup_sync(self) -> None:
@ -518,3 +547,4 @@ class BackupPane(Container):
modal_prog.phase_report("Backup Sync", "error", show_back=False) modal_prog.phase_report("Backup Sync", "error", show_back=False)
finally: finally:
await self.engine.close_connections() await self.engine.close_connections()
self._validate()

438
src/ui/backup_stats.py Normal file
View file

@ -0,0 +1,438 @@
import json
from pathlib import Path
from datetime import datetime
import asyncio
from textual.app import ComposeResult
from textual.screen import Screen
from textual.containers import Container, Vertical, VerticalScroll, Horizontal
from textual.widgets import Button, Label, Rule, Tree
from textual import work
from rich.text import Text
from rich.style import Style
class BackupStatsScreen(Screen[None]):
"""Full-screen view for displaying detailed backup statistics."""
DEFAULT_CSS = """
BackupStatsScreen {
background: $surface;
}
#bs_scroll_container {
padding: 1 2;
}
#bs_header {
height: auto;
layout: horizontal;
padding-bottom: 1;
margin-bottom: 1;
border-bottom: solid $accent;
}
#bs_header_left {
width: 1fr;
height: auto;
layout: horizontal;
}
#bs_server_info {
layout: vertical;
height: auto;
margin-left: 1;
}
.header_title {
text-style: bold;
}
.header_subtitle {
color: $text-muted;
}
#bs_header_right {
width: auto;
height: auto;
layout: vertical;
content-align: right middle;
}
.top_card_row {
height: auto;
layout: horizontal;
margin-bottom: 1;
}
.stat_card {
width: 1fr;
height: 6;
layout: vertical;
border: solid $accent;
padding: 1;
margin-right: 1;
background: $boost;
}
.stat_card:focus {
border: solid $secondary;
}
.card_title {
color: $text-muted;
text-style: bold;
}
.card_value {
text-style: bold;
}
.entity_row {
height: auto;
layout: horizontal;
margin-bottom: 1;
}
.wide_card {
width: 1fr;
height: auto;
layout: vertical;
border: solid $accent;
padding: 1;
margin-right: 1;
background: $boost;
}
.inner_card_row {
height: auto;
layout: horizontal;
margin-top: 1;
}
.inner_stat {
width: 1fr;
height: 4;
layout: vertical;
border: solid $surface;
background: $surface;
padding: 0 1;
content-align: center middle;
margin-right: 1;
}
.inner_stat_value {
text-style: bold;
content-align: center middle;
width: 100%;
}
.inner_stat_label {
color: $text-muted;
content-align: center middle;
width: 100%;
}
#detailed_stats_container {
height: auto;
margin-top: 2;
}
#stats_tree {
height: auto;
border: solid $accent;
background: $boost;
}
#bs_actions {
height: auto;
margin-top: 1;
layout: horizontal;
}
#bs_actions Button {
width: 100%;
}
"""
def __init__(self, profile_name: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.profile_name = profile_name
self.base_dir = Path(f"ReaperFiles-{self.profile_name}")
self.profile_path = None
self.backup_path = None
def compose(self) -> ComposeResult:
with VerticalScroll(id="bs_scroll_container"):
# Header
with Horizontal(id="bs_header"):
with Horizontal(id="bs_header_left"):
yield Label("🚀", id="bs_server_icon") # Fallback icon
with Vertical(id="bs_server_info"):
yield Label("Loading...", id="bs_name", classes="header_title")
yield Label("...", id="bs_id", classes="header_subtitle")
with Vertical(id="bs_header_right"):
yield Label("LAST BACKUP", classes="header_subtitle", id="bs_last_backup_lbl")
yield Label("-", id="bs_last_backup", classes="header_title")
# 4 Top Cards
with Horizontal(classes="top_card_row"):
with Vertical(classes="stat_card"):
yield Label("# MESSAGES", classes="card_title")
yield Label("-", id="bs_val_msgs", classes="card_value")
with Vertical(classes="stat_card"):
yield Label("💭 THREADS", classes="card_title")
yield Label("-", id="bs_val_threads", classes="card_value")
with Vertical(classes="stat_card"):
yield Label("📎 ATTACHMENTS", classes="card_title")
yield Label("-", id="bs_val_files", classes="card_value")
with Vertical(classes="stat_card"):
yield Label("💾 ATTACHMENTS SIZE", classes="card_title")
yield Label("-", id="bs_val_size", classes="card_value")
# 2 Main Secondary Cards (Combined)
with Horizontal(classes="entity_row"):
with Vertical(classes="wide_card"):
yield Label("👥 SERVER ENTITIES & COVERAGE", classes="card_title")
with Horizontal(classes="inner_card_row"):
with Vertical(classes="inner_stat"):
yield Label("-", id="bs_val_members", classes="inner_stat_value")
yield Label("TOTAL MEMBERS", classes="inner_stat_label")
with Vertical(classes="inner_stat"):
yield Label("-", id="bs_val_roles", classes="inner_stat_value")
yield Label("ROLES DEFINED", classes="inner_stat_label")
with Vertical(classes="inner_stat"):
yield Label("-", id="bs_val_emojis", classes="inner_stat_value")
yield Label("CUSTOM EMOJIS", classes="inner_stat_label")
with Vertical(classes="inner_stat"):
yield Label("-", id="bs_val_stickers", classes="inner_stat_value")
yield Label("CUSTOM STICKERS", classes="inner_stat_label")
with Vertical(classes="inner_stat"):
yield Label("-", id="bs_val_coverage", classes="inner_stat_value")
yield Label("CHANNEL BACKUP", classes="inner_stat_label")
# Detailed Tree
with Vertical(id="detailed_stats_container"):
yield Label("DETAILED CHANNEL & CATEGORY STATISTICS", classes="card_title", id="tree_title")
yield Tree("Categories", id="stats_tree")
with Horizontal(id="bs_actions"):
yield Button("Back", id="btn_back", variant="default")
def on_mount(self) -> None:
self.stats_tree = self.query_one("#stats_tree", Tree)
self.stats_tree.root.expand()
self.stats_tree.show_root = False
# Add a header row to the tree root, purely for visual columns
header_text = self._format_tree_row("NAME", "MESSAGES", "THREADS", "FILES", "SIZE")
header_text.stylize("bold")
self.stats_tree.root.set_label(header_text)
self.stats_tree.show_root = True
self.load_data()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn_back":
self.dismiss()
def _format_size(self, size_bytes: int) -> str:
if size_bytes == 0:
return "0 B"
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
if size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.2f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def _format_tree_row(self, name: str, msgs, threads, files, size) -> Text:
"""Pads and aligns columns for the tree view to simulate a table."""
col_name = str(name)[:30].ljust(35)
col_msg = str(msgs).rjust(12)
col_thd = str(threads).rjust(12)
col_file = str(files).rjust(12)
col_size = str(size).rjust(15)
return Text(f"{col_name}{col_msg}{col_thd}{col_file}{col_size}")
@work(exclusive=True)
async def load_data(self) -> None:
try:
# Locate the correct backup directory inside base_dir
if not self.base_dir.exists():
raise FileNotFoundError(f"Config directory {self.base_dir} not found.")
target_dir = None
# The exporter creates directories like DISCORD_BACKUP-<id> or <name>-<id>
# We look for the first one that contains a server_profile
for child in self.base_dir.iterdir():
if child.is_dir() and (child / "server_profile" / "profile.json").exists():
target_dir = child
# If it's a recent backup, prefer it. We'll simply grab the first valid one if multiple exist.
# Usually there's only one valid backup per profile.
break
if not target_dir:
raise FileNotFoundError(f"No valid backup found in {self.base_dir}")
self.profile_path = target_dir / "server_profile"
self.backup_path = target_dir / "message_backup"
# 1. Profile / Server Info
server_name = "Unknown Server"
server_id = "0"
last_backup_str = "Never"
profile_file = self.profile_path / "profile.json"
if profile_file.exists():
with open(profile_file, "r", encoding="utf-8") as f:
data = json.load(f)
server_name = data.get("name", server_name)
server_id = data.get("id", server_id)
ts = data.get("last_backup")
if ts:
try:
dt = datetime.fromisoformat(ts)
last_backup_str = dt.strftime("%d %b, %Y - %H:%M")
except Exception:
last_backup_str = ts
self.query_one("#bs_name", Label).update(f"{server_name}")
self.query_one("#bs_id", Label).update(f"{server_id}")
self.query_one("#bs_last_backup", Label).update(f"{last_backup_str}")
# 2. Assets / Entities
member_count = 0
emoji_count = 0
sticker_count = 0
# Fetch members from users/user_info.json
if self.backup_path:
user_info_file = self.backup_path / "users" / "user_info.json"
if user_info_file.exists():
try:
with open(user_info_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
member_count = len(data)
elif isinstance(data, dict):
member_count = len(data)
except Exception:
pass
assets_file = self.profile_path / "assets.json"
if assets_file.exists():
with open(assets_file, "r", encoding="utf-8") as f:
data = json.load(f)
emoji_count = len(data.get("emojis", []))
sticker_count = len(data.get("stickers", []))
role_count = 0
roles_file = self.profile_path / "roles.json"
if roles_file.exists():
with open(roles_file, "r", encoding="utf-8") as f:
role_count = len(json.load(f))
self.query_one("#bs_val_members", Label).update(f"{member_count}")
self.query_one("#bs_val_roles", Label).update(f"{role_count}")
self.query_one("#bs_val_emojis", Label).update(f"{emoji_count}")
self.query_one("#bs_val_stickers", Label).update(f"{sticker_count}")
# 3. Structure & Per-Channel Stats
structure_file = self.profile_path / "structure.json"
total_channels = 0
backed_up_channels = 0
total_msgs = 0
total_threads = 0
total_files = 0
total_size = 0
cat_nodes = [] # Collect category data
if structure_file.exists():
with open(structure_file, "r", encoding="utf-8") as f:
structure = json.load(f)
for cat in structure:
cat_name = cat.get("name", "Unknown Category").upper()
c_msgs = 0
c_thds = 0
c_files = 0
c_size = 0
chan_list = []
for chan in cat.get("channels", []):
total_channels += 1
ch_id = chan.get("id")
ch_name = f"# {chan.get('name', 'unknown')}"
m_count = 0
t_count = 0
f_count = 0
s_bytes = 0
is_backed_up = False
msg_file = self.backup_path / str(ch_id) / "messages.json"
if msg_file.exists():
is_backed_up = True
backed_up_channels += 1
try:
with open(msg_file, "r", encoding="utf-8") as mf:
mdata = json.load(mf)
m_count = mdata.get("messageCount", 0)
t_count = mdata.get("threadCount", 0)
f_count = mdata.get("numberOfAttachments", 0)
s_bytes = mdata.get("totalAttachmentSizeBytes", 0)
except Exception:
pass
chan_list.append({
"name": ch_name,
"msgs": m_count if is_backed_up else "NA",
"threads": t_count if is_backed_up else "NA",
"files": f_count if is_backed_up else "NA",
"size": s_bytes,
"is_backed_up": is_backed_up
})
c_msgs += m_count
c_thds += t_count
c_files += f_count
c_size += s_bytes
total_msgs += c_msgs
total_threads += c_thds
total_files += c_files
total_size += c_size
cat_nodes.append({
"name": cat_name,
"channels": chan_list,
"msgs": c_msgs,
"threads": c_thds,
"files": c_files,
"size": c_size
})
# 4. Global Stats
self.query_one("#bs_val_msgs", Label).update(f"{total_msgs}")
self.query_one("#bs_val_threads", Label).update(f"{total_threads}")
self.query_one("#bs_val_files", Label).update(f"{total_files}")
self.query_one("#bs_val_size", Label).update(f"{self._format_size(total_size)}")
self.query_one("#bs_val_coverage", Label).update(f"{backed_up_channels} / {total_channels}")
# 5. Build Tree
for cat in cat_nodes:
cat_lbl = self._format_tree_row(f"{cat['name']}", cat['msgs'], cat['threads'], cat['files'], self._format_size(cat['size']))
cat_lbl.stylize("bold yellow")
node = self.stats_tree.root.add(cat_lbl, expand=True)
for ch in cat["channels"]:
size_str = self._format_size(ch['size']) if ch['is_backed_up'] else "NA"
ch_lbl = self._format_tree_row(f" {ch['name']}", ch['msgs'], ch['threads'], ch['files'], size_str)
if ch['is_backed_up']:
ch_lbl.stylize("bold white")
else:
ch_lbl.stylize("dim white") # Textual 'dim' looks like a dull grey
node.add_leaf(ch_lbl)
except Exception as e:
self.query_one("#bs_name", Label).update(f"[red]Error loading data[/red]")
self.query_one("#bs_id", Label).update(f"[red]{e}[/red]")

View file

@ -57,17 +57,17 @@ class ProgressScreen(Screen[None]):
layout: horizontal; layout: horizontal;
border: solid cyan; border: solid cyan;
padding: 1; padding: 1;
margin-bottom: 1; margin-bottom: 0;
display: none; display: none;
} }
.stat_label { width: 1fr; content-align: center middle; text-style: bold; } .stat_label { width: 1fr; content-align: center middle; text-style: bold; }
#prog_log { height: 1fr; margin-bottom: 1; border: solid $primary; } #prog_log { height: 1fr; margin-bottom: 0; border: solid $primary; }
#live_log { height: 10; margin-bottom: 1; border: solid yellow; } #live_log { height: 10; margin-bottom: 0; border: solid yellow; }
#prog_item_status { margin-bottom: 1; text-style: bold; color: cyan; width: 100%; text-align: center; } #prog_item_status { margin-bottom: 1; text-style: bold; color: cyan; width: 100%; text-align: center; }
#info_container { height: auto; layout: vertical; border: solid cyan; padding: 1; margin-bottom: 1; display: none; } #info_container { height: auto; layout: vertical; border: solid cyan; padding: 1; margin-bottom: 0; display: none; }
.info_label { text-style: bold; content-align: center middle; width: 100%; color: cyan; } .info_label { text-style: bold; content-align: center middle; width: 100%; color: cyan; }
#prog_actions { height: auto; margin-top: 0; dock: bottom; margin-bottom: 0; layout: vertical; } #prog_actions { height: auto; margin-top: 0; dock: bottom; margin-bottom: 0; layout: vertical; }