@danhachuel/thunderbolt 0.3.38 → 0.3.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.3.38
5
+ > **Versão deste manual:** 0.3.39
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.3.38 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.3.39 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.3.38 install
109
+ npx --yes @danhachuel/thunderbolt@0.3.39 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -465,6 +465,8 @@ O cartão mostra o nicho imediatamente abaixo do nome. Os quatro blocos compacto
465
465
 
466
466
  A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal, dentro de um expander fechado por defeito, e não dentro de Criação de Vídeos. Clique no expander e depois em **Actualizar últimos 10 vídeos** para consultar o feed RSS público do YouTube sem Data API Key. Os resultados são guardados em `storage/state/channel_videos.json`. A única vista disponível é **Lista**, que apresenta título, data, URL, estado e botão **Editar vídeo**. A edição local permite alterar título, estado, data, URL e notas. Essas alterações são overrides de gestão local e não publicam automaticamente no YouTube.
467
467
 
468
+ A página **Configurações > Logs** aparece no menu entre **Notificações** e **Configuração API**. Mostra uma projecção unificada das tarefas e notificações persistentes, com filtro livre por operação, filtros por operação e estado e as colunas mínimas **Operação**, **Estado**, **Data** e **Hora**. A tabela também mostra **Registo**, **Origem**, **Progresso** e **Detalhes**; estados como pendente, em execução, concluído, publicado, falha, cancelado e bloqueado são apresentados quando existirem no storage local.
469
+
468
470
  ## Canais em lote por conta Google/YouTube
469
471
 
470
472
  A subaba **Canais em lote gmail**, dentro de **Canais Youtube**, não lê e-mails, mensagens, contactos ou a caixa Gmail. O nome identifica a conta Google/Gmail que gere os canais YouTube. Configure cada conta em **Configurações > Contas Google**, preenchendo e-mail, OAuth Client ID, OAuth Client Secret e o `sessionInfo token desta conta Google`.
@@ -481,7 +483,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
481
483
 
482
484
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
483
485
 
484
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.38 install`; o instalador detecta e reutiliza o que já estiver válido.
486
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.39 install`; o instalador detecta e reutiliza o que já estiver válido.
485
487
 
486
488
  ### Niche Finder Apify
487
489
 
package/README.md CHANGED
@@ -69,9 +69,9 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
69
69
 
70
70
  A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
71
71
 
72
- ## Navegação da UI 0.3.38
72
+ ## Navegação da UI 0.3.39
73
73
 
74
- A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
74
+ A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
75
75
 
76
76
  A subaba **Tutorial Supabase** apresenta o guia de criação das tabelas `plans` e `posts` e do bucket `instagram-images`, com ligação para a fonte original no GitHub. As duas frases promocionais da comunidade foram omitidas. Todas as subabas internas também são localizadas pelo helper nativo de tabs:
77
77
 
@@ -85,7 +85,7 @@ Em **Configuração API > API Keys**, a subaba **Fontes de materiais** segue o p
85
85
 
86
86
  A subaba **Serviços e modelos** mantém as credenciais de providers e serviços que não são fontes de materiais. A chave `gemini_image_api_key` da Nano Banana continua separada de `gemini_api_key` do LLM textual. `INNERTUBE_API_KEY` permanece exclusivamente em **Configurações > Contas Google**, no bloco de configuração global fora dos cartões, e não é duplicada em API Keys.
87
87
 
88
- A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada. A subaba **Geral** mantém esse centro local; a subaba **Telegram** permite configurar Bot Token, Chat ID e proxy opcional e envia as mesmas notificações pela Bot API oficial, sem polling ou mensagens recebidas.
88
+ A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada. A subaba **Geral** mantém esse centro local; a subaba **Telegram** permite configurar Bot Token, Chat ID e proxy opcional e envia as mesmas notificações pela Bot API oficial, sem polling ou mensagens recebidas. A página **Configurações > Logs**, colocada imediatamente abaixo de Notificações e acima de Configuração API, apresenta o histórico unificado de tarefas e notificações, com filtro livre por operação, filtros adicionais por operação e estado, e as colunas Operação, Estado, Data, Hora, Registo, Origem, Progresso e Detalhes.
89
89
 
90
90
  A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https://github.com/yt-dlp/yt-dlp) para descarregar vídeos e áudio de URLs públicas, com qualidade, contentor, formato de áudio, legendas, metadados, playlists, progresso e histórico local em `storage/downloads/` e `storage/state/media_downloads.json`. Conversão e combinação de streams podem exigir FFmpeg.
91
91
 
package/app/main.py CHANGED
@@ -42,6 +42,7 @@ from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_ACTIVE_CARD_KEY, LLM_PROV
42
42
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
43
43
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
44
44
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
45
+ from hermes_ui.logs import list_logs, logs_to_rows
45
46
  from hermes_ui.languages import LANGUAGE_CODES, VIDEO_LANGUAGE_CODES, LANGUAGE_FLAG_DATA_URIS, language_code, language_label, ui_language_menu_label, ui_text, video_language_label, video_language_options
46
47
  from hermes_ui.api_key_tests import test_apify_credentials, test_kaggle_credentials, test_material_source_credentials, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
47
48
  from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
@@ -5286,6 +5287,65 @@ def _render_telegram_notification_settings() -> None:
5286
5287
  st.info("Telegram está desactivado. As notificações continuam disponíveis na subaba Geral.")
5287
5288
 
5288
5289
 
5290
+ def render_logs():
5291
+ """Render the unified local activity log before the API configuration page."""
5292
+ st.title("Logs")
5293
+ st.caption("Histórico unificado das operações do Thunderbolt. Os registos são reconstruídos a partir das tarefas e notificações persistidas no storage local.")
5294
+ try:
5295
+ reconcile_persisted_notifications()
5296
+ except Exception:
5297
+ pass
5298
+
5299
+ initial_records = list_logs(limit=500)
5300
+ catalog_operations = {str(item.get("label") or "Operação") for item in notification_event_catalog()}
5301
+ logged_operations = {str(item.get("operation") or "Operação") for item in initial_records}
5302
+ operation_options = ["Todas"] + sorted(catalog_operations | logged_operations)
5303
+ status_options = ["Todos"] + sorted({str(item.get("status") or "Desconhecido") for item in initial_records})
5304
+ filter_cols = st.columns([2.2, 1.25, 1.1])
5305
+ with filter_cols[0]:
5306
+ query = st.text_input(
5307
+ "Filtrar operações",
5308
+ placeholder="Pesquisar por operação, registo, canal ou detalhes",
5309
+ key="logs_query_filter",
5310
+ )
5311
+ with filter_cols[1]:
5312
+ selected_operation = st.selectbox("Operação", operation_options, key="logs_operation_filter")
5313
+ with filter_cols[2]:
5314
+ selected_status = st.selectbox("Estado", status_options, key="logs_status_filter")
5315
+
5316
+ action_cols = st.columns([1, 1, 3])
5317
+ with action_cols[0]:
5318
+ if st.button("Actualizar logs", use_container_width=True):
5319
+ st.rerun()
5320
+ with action_cols[1]:
5321
+ st.metric("Registos", len(initial_records))
5322
+ with action_cols[2]:
5323
+ st.caption("São incluídos estados pendentes, em execução, concluídos, publicados, falhados, cancelados e bloqueados quando existirem.")
5324
+
5325
+ operation_filter = "" if selected_operation == "Todas" else selected_operation
5326
+ status_filter = "" if selected_status == "Todos" else selected_status
5327
+ records = list_logs(operation=operation_filter, query=query, status=status_filter, limit=500)
5328
+ if not records:
5329
+ st.info("Ainda não existem logs para os filtros seleccionados.")
5330
+ return
5331
+ rows = logs_to_rows(records)
5332
+ st.dataframe(
5333
+ rows,
5334
+ use_container_width=True,
5335
+ hide_index=True,
5336
+ column_config={
5337
+ "Operação": st.column_config.TextColumn("Operação", width="medium"),
5338
+ "Estado": st.column_config.TextColumn("Estado", width="small"),
5339
+ "Data": st.column_config.TextColumn("Data", width="small"),
5340
+ "Hora": st.column_config.TextColumn("Hora", width="small"),
5341
+ "Registo": st.column_config.TextColumn("Registo", width="medium"),
5342
+ "Origem": st.column_config.TextColumn("Origem", width="small"),
5343
+ "Progresso": st.column_config.TextColumn("Progresso", width="small"),
5344
+ "Detalhes": st.column_config.TextColumn("Detalhes", width="large"),
5345
+ },
5346
+ )
5347
+
5348
+
5289
5349
  def render_notifications():
5290
5350
  st.title("Notificações")
5291
5351
  st.caption("Centro de notificações internas persistentes do Thunderbolt. As conclusões são guardadas no storage local e aparecem quando a aplicação é actualizada.")
@@ -5729,6 +5789,7 @@ def main():
5729
5789
  settings_items = [
5730
5790
  ("MCP", ":material/hub:", "MCP"),
5731
5791
  ("Notificações", ":material/notifications:", "Notificações"),
5792
+ ("Logs", ":material/description:", "Logs"),
5732
5793
  ("Configuração API", ":material/settings:", "Configuração API"),
5733
5794
  ]
5734
5795
  niche_finder_items = [
@@ -5861,6 +5922,7 @@ def main():
5861
5922
  "Contas Google": render_google_accounts,
5862
5923
  "Configuração API": render_settings,
5863
5924
  "Notificações": render_notifications,
5925
+ "Logs": render_logs,
5864
5926
  }
5865
5927
  try:
5866
5928
  reconcile_persisted_notifications()
@@ -0,0 +1,233 @@
1
+ """Unified activity logs projected from Thunderbolt's persisted local state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ from . import storage
9
+ from .notifications import EVENTS_BY_CODE, list_notifications, reconcile_persisted_notifications
10
+
11
+ MAX_LOGS = 500
12
+
13
+ STATUS_LABELS = {
14
+ "to_do": "Pendente",
15
+ "pending": "Pendente",
16
+ "queued": "Na fila",
17
+ "doing": "Em execução",
18
+ "running": "Em execução",
19
+ "processing": "Em execução",
20
+ "done": "Concluído",
21
+ "completed": "Concluído",
22
+ "complete": "Concluído",
23
+ "success": "Concluído",
24
+ "published": "Publicado",
25
+ "failed": "Falha",
26
+ "error": "Falha",
27
+ "cancelled": "Cancelado",
28
+ "canceled": "Cancelado",
29
+ "blocked": "Bloqueado",
30
+ }
31
+
32
+ TASK_OPERATION_CODES = {
33
+ "video": "video_completed",
34
+ "music": "music_completed",
35
+ "automation": "automation_completed",
36
+ }
37
+
38
+
39
+ def _status_label(value: Any) -> str:
40
+ raw = str(value or "unknown").strip().lower()
41
+ return STATUS_LABELS.get(raw, raw.replace("_", " ").capitalize() or "Desconhecido")
42
+
43
+
44
+ def _parse_datetime(value: Any) -> datetime:
45
+ text = str(value or "").strip()
46
+ if not text:
47
+ return datetime.min.replace(tzinfo=timezone.utc)
48
+ try:
49
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
50
+ except ValueError:
51
+ return datetime.min.replace(tzinfo=timezone.utc)
52
+ if parsed.tzinfo is None:
53
+ return parsed.replace(tzinfo=timezone.utc)
54
+ return parsed.astimezone(timezone.utc)
55
+
56
+
57
+ def format_log_date_time(value: Any) -> tuple[str, str]:
58
+ """Return a stable local date and time for the Logs table."""
59
+ parsed = _parse_datetime(value)
60
+ if parsed == datetime.min.replace(tzinfo=timezone.utc):
61
+ return "—", "—"
62
+ local = parsed.astimezone()
63
+ return local.strftime("%d/%m/%Y"), local.strftime("%H:%M:%S")
64
+
65
+
66
+ def _task_operation_code(task: dict[str, Any]) -> str:
67
+ if bool(task.get("music_mode")) or str(task.get("style_wide") or "").strip().lower() == "music":
68
+ return TASK_OPERATION_CODES["music"]
69
+ if str(task.get("creation_mode") or "").strip().lower() == "automation":
70
+ return TASK_OPERATION_CODES["automation"]
71
+ return TASK_OPERATION_CODES["video"]
72
+
73
+
74
+ def _operation_label(operation_code: str, fallback: str = "Operação") -> str:
75
+ event = EVENTS_BY_CODE.get(operation_code)
76
+ return str(event.get("label") if event else fallback or operation_code or "Operação")
77
+
78
+
79
+ def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
80
+ task_id = str(task.get("id") or "").strip()
81
+ if not task_id:
82
+ return None
83
+ operation_code = _task_operation_code(task)
84
+ state = str(task.get("state") or "unknown").strip().lower()
85
+ occurred_at = task.get("updated_at") or task.get("completed_at") or task.get("created_at")
86
+ date, time = format_log_date_time(occurred_at)
87
+ title = str(task.get("title") or task.get("topic") or "Actividade").strip()
88
+ channel = str(task.get("channel_name") or "").strip()
89
+ stage = str(task.get("stage") or "").strip()
90
+ progress = task.get("progress")
91
+ try:
92
+ progress_value = max(0, min(100, int(progress))) if progress is not None else None
93
+ except (TypeError, ValueError):
94
+ progress_value = None
95
+ details: list[str] = []
96
+ if channel:
97
+ details.append(channel)
98
+ if stage:
99
+ details.append(f"Etapa: {stage}")
100
+ if progress_value is not None:
101
+ details.append(f"Progresso: {progress_value}%")
102
+ error = str(task.get("error") or "").strip()
103
+ if error:
104
+ details.append(error[:500])
105
+ return {
106
+ "id": f"task:{task_id}",
107
+ "operation_code": operation_code,
108
+ "operation": _operation_label(operation_code),
109
+ "status_code": state,
110
+ "status": _status_label(state),
111
+ "occurred_at": str(occurred_at or ""),
112
+ "date": date,
113
+ "time": time,
114
+ "source": "Tarefas",
115
+ "record": title,
116
+ "details": " · ".join(details),
117
+ "progress": progress_value,
118
+ "task_id": task_id,
119
+ }
120
+
121
+
122
+ def _notification_status(entry: dict[str, Any]) -> str:
123
+ metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
124
+ explicit = metadata.get("status") or entry.get("status")
125
+ if explicit:
126
+ return str(explicit)
127
+ event_type = str(entry.get("event_type") or "").lower()
128
+ label = str(entry.get("label") or "").lower()
129
+ return "failed" if event_type.endswith("_failed") or "falhou" in label or "erro" in label else "completed"
130
+
131
+
132
+ def _notification_log(entry: dict[str, Any]) -> dict[str, Any] | None:
133
+ entry_id = str(entry.get("id") or "").strip()
134
+ if not entry_id:
135
+ return None
136
+ event_type = str(entry.get("event_type") or "").strip()
137
+ if not event_type:
138
+ return None
139
+ status_code = _notification_status(entry).strip().lower()
140
+ occurred_at = entry.get("created_at") or ""
141
+ date, time = format_log_date_time(occurred_at)
142
+ metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
143
+ public_metadata = [f"{key}: {value}" for key, value in metadata.items() if value not in (None, "")]
144
+ return {
145
+ "id": f"notification:{entry_id}",
146
+ "operation_code": event_type,
147
+ "operation": str(entry.get("label") or _operation_label(event_type)),
148
+ "status_code": status_code,
149
+ "status": _status_label(status_code),
150
+ "occurred_at": str(occurred_at),
151
+ "date": date,
152
+ "time": time,
153
+ "source": "Notificações",
154
+ "record": str(entry.get("title") or entry.get("label") or "Notificação"),
155
+ "details": " · ".join([str(entry.get("message") or "").strip(), *public_metadata]).strip(" ·")[:1000],
156
+ "progress": None,
157
+ "task_id": str(metadata.get("task_id") or ""),
158
+ }
159
+
160
+
161
+ def _should_skip_notification(entry: dict[str, Any], task_ids: set[str]) -> bool:
162
+ """Avoid duplicating the canonical task completion/failure with its notification."""
163
+ event_type = str(entry.get("event_type") or "")
164
+ metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
165
+ task_id = str(metadata.get("task_id") or "")
166
+ return bool(task_id and task_id in task_ids and event_type in {"video_completed", "music_completed", "activity_failed"})
167
+
168
+
169
+ def list_logs(*, operation: str = "", query: str = "", status: str = "", limit: int = MAX_LOGS) -> list[dict[str, Any]]:
170
+ """Return a unified, newest-first projection of local activity records."""
171
+ try:
172
+ reconcile_persisted_notifications()
173
+ except Exception:
174
+ # A diagnostic page must remain available even if one source is malformed.
175
+ pass
176
+ tasks = storage.read_json("tasks.json", [])
177
+ tasks = [item for item in tasks if isinstance(item, dict)] if isinstance(tasks, list) else []
178
+ task_ids = {str(item.get("id") or "") for item in tasks if item.get("id")}
179
+ records = [item for item in (_task_log(task) for task in tasks) if item]
180
+ notifications = list_notifications(limit=MAX_LOGS)
181
+ records.extend(
182
+ item
183
+ for entry in notifications
184
+ if not _should_skip_notification(entry, task_ids)
185
+ for item in [_notification_log(entry)]
186
+ if item
187
+ )
188
+ operation_filter = str(operation or "").strip().casefold()
189
+ query_filter = str(query or "").strip().casefold()
190
+ status_filter = str(status or "").strip().casefold()
191
+ if operation_filter:
192
+ records = [item for item in records if str(item.get("operation") or "").casefold() == operation_filter]
193
+ if status_filter:
194
+ records = [item for item in records if str(item.get("status") or "").casefold() == status_filter]
195
+ if query_filter:
196
+ records = [
197
+ item
198
+ for item in records
199
+ if query_filter in " ".join(str(item.get(key) or "") for key in ("operation", "status", "record", "details", "source")).casefold()
200
+ ]
201
+ records.sort(key=lambda item: _parse_datetime(item.get("occurred_at")), reverse=True)
202
+ try:
203
+ requested_limit = int(limit)
204
+ except (TypeError, ValueError):
205
+ requested_limit = MAX_LOGS
206
+ return records[: max(1, min(requested_limit, MAX_LOGS))]
207
+
208
+
209
+ def log_operation_options() -> list[str]:
210
+ records = list_logs(limit=MAX_LOGS)
211
+ return ["Todas"] + sorted({str(item.get("operation") or "Operação") for item in records})
212
+
213
+
214
+ def log_status_options() -> list[str]:
215
+ records = list_logs(limit=MAX_LOGS)
216
+ return ["Todos"] + sorted({str(item.get("status") or "Desconhecido") for item in records})
217
+
218
+
219
+ def logs_to_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
220
+ """Convert the internal projection into safe scalar rows for Streamlit."""
221
+ return [
222
+ {
223
+ "Operação": item.get("operation") or "Operação",
224
+ "Estado": item.get("status") or "Desconhecido",
225
+ "Data": item.get("date") or "—",
226
+ "Hora": item.get("time") or "—",
227
+ "Registo": item.get("record") or "—",
228
+ "Origem": item.get("source") or "—",
229
+ "Progresso": f"{item['progress']}%" if item.get("progress") is not None else "—",
230
+ "Detalhes": item.get("details") or "—",
231
+ }
232
+ for item in records
233
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.38",
3
+ "version": "0.3.39",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",