@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.
- package/MANUAL-INSTALACAO.md +6 -4
- package/README.md +3 -3
- package/app/main.py +62 -0
- package/hermes_ui/logs.py +233 -0
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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**, **
|
|
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