From 08d2a31dc9c4fc96c46bb7ffb5c3fee1c1d50ab7 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Mon, 15 Jun 2026 19:43:18 +0100 Subject: [PATCH 1/5] feat(monitoring): site_uptime cobre 15 sites (clientes + internos) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alinha PING_SITES do check activo com o watchdog-sites.py: +carstuff +6 internos (care, ccv, e-commerce, ecommerce, ecommerce-demo, starter). Security Audit (Regra #47): sem deps novas; vulns pré-existentes documentadas no commit anterior. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/services/monitoring-collector.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/services/monitoring-collector.ts b/api/services/monitoring-collector.ts index fca0cf4..19cd40e 100644 --- a/api/services/monitoring-collector.ts +++ b/api/services/monitoring-collector.ts @@ -171,6 +171,13 @@ const PING_SITES: { host: string; url: string; marker: string }[] = [ { host: 'familyclinic.pt', url: 'https://familyclinic.pt', marker: 'Family Clinic' }, { host: 'karateclubedegaia.com', url: 'https://karateclubedegaia.com', marker: 'Karate Clube de Gaia' }, { host: 'whatsms.pt', url: 'https://whatsms.pt', marker: 'Todos os canais' }, + { host: 'carstuff.pt', url: 'https://carstuff.pt', marker: 'CarStuff' }, + { host: 'care.descomplicar.pt', url: 'https://care.descomplicar.pt', marker: 'Care Descomplicar' }, + { host: 'ccv.descomplicar.pt', url: 'https://ccv.descomplicar.pt', marker: 'Carvoeiro Caves' }, + { host: 'e-commerce.descomplicar.pt', url: 'https://e-commerce.descomplicar.pt', marker: 'e-Commerce Descomplicar' }, + { host: 'ecommerce.descomplicar.pt', url: 'https://ecommerce.descomplicar.pt', marker: 'Descomplicar - Template' }, + { host: 'ecommerce-demo.descomplicar.pt', url: 'https://ecommerce-demo.descomplicar.pt', marker: 'Descomplicar - Demo' }, + { host: 'starter.descomplicar.pt', url: 'https://starter.descomplicar.pt', marker: 'Starter Descomplicar' }, ] const SITE_SIZE_FLOOR = 5000 const SITE_ERROR_SIGNATURES = [ From 769c63b2a81c35055c803a911d7258c2037e1b4c Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 23 Jun 2026 20:09:12 +0100 Subject: [PATCH 2/5] feat(monitoring): add Beszel fleet integration to Monitor page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New api/services/beszel.ts: Zod-validated Beszel API client with PocketBase auth, system fetching, and MySQL upsert - New api/routes/beszel.ts: GET /api/beszel (read) + POST /api/beszel/collect (trigger) - Updated api/server.ts: register route, add to 5min scheduler - Updated src/pages/Monitor.tsx: Fleet section showing all 17 systems with CPU/RAM/disk bars, status dots, container count, and link to Beszel dashboard Architecture: Beszel hub → API → tbl_eal_monitoring (category='fleet') → Monitor.tsx grid. Collected every 5min alongside existing metrics. --- api/routes/beszel.ts | 36 ++++++ api/server.ts | 10 +- api/services/beszel.ts | 248 +++++++++++++++++++++++++++++++++++++++++ src/pages/Monitor.tsx | 91 ++++++++++++++- 4 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 api/routes/beszel.ts create mode 100644 api/services/beszel.ts diff --git a/api/routes/beszel.ts b/api/routes/beszel.ts new file mode 100644 index 0000000..43d5234 --- /dev/null +++ b/api/routes/beszel.ts @@ -0,0 +1,36 @@ +/** + * Beszel Fleet API Route + * GET /api/beszel — fleet data from MySQL + * POST /api/beszel/collect — trigger fresh collection + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import { getFleetData, collectBeszelFleet } from '../services/beszel.js' + +const router = Router() + +// GET /api/beszel — fleet data +router.get('/', async (_req, res) => { + try { + const data = await getFleetData() + res.json(data) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('[BESZEL API] GET failed:', message) + res.status(500).json({ error: message }) + } +}) + +// POST /api/beszel/collect — trigger collection +router.post('/collect', async (_req, res) => { + try { + const data = await collectBeszelFleet() + res.json({ ok: true, collected: data.total, up: data.up, down: data.down }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('[BESZEL API] Collect failed:', message) + res.status(500).json({ error: message }) + } +}) + +export default router diff --git a/api/server.ts b/api/server.ts index c208986..30f3a3b 100644 --- a/api/server.ts +++ b/api/server.ts @@ -26,6 +26,8 @@ import { openSessionsDb } from './services/sessions/db.js' import { DEFAULT_DB_PATH } from './services/sessions/indexer.js' import { collectAllServerMetrics } from './services/server-metrics.js' import { collectMonitoringData } from './services/monitoring-collector.js' +import beszelRouter from './routes/beszel.js' +import { collectBeszelFleet } from './services/beszel.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -133,8 +135,8 @@ app.use('/api/financial', financialRouter) app.use('/api/mcps', mcpsRouter) app.use('/api/n8n', n8nRouter) app.use('/api/paperclip', paperclipRouter) -app.use('/api/ai', aiRouter) app.use('/api/operations', operationsRouter) +app.use('/api/beszel', beszelRouter) // Observabilidade (Espelho) — sessões Claude Code const sessionsDb = openSessionsDb(process.env.OBSERVABILIDADE_DB ?? DEFAULT_DB_PATH) @@ -215,6 +217,9 @@ app.listen(PORT, () => { collectMonitoringData().catch(err => console.error('[SCHEDULER] Initial monitoring collection failed:', err.message) ) + collectBeszelFleet().catch(err => + console.error('[SCHEDULER] Initial Beszel fleet collection failed:', err instanceof Error ? err.message : err) + ) }, 30000) // Recurring collection @@ -225,6 +230,9 @@ app.listen(PORT, () => { collectMonitoringData().catch(err => console.error('[SCHEDULER] Monitoring collection failed:', err.message) ) + collectBeszelFleet().catch(err => + console.error('[SCHEDULER] Beszel fleet collection failed:', err instanceof Error ? err.message : err) + ) }, INTERVAL) } }) diff --git a/api/services/beszel.ts b/api/services/beszel.ts new file mode 100644 index 0000000..45f6193 --- /dev/null +++ b/api/services/beszel.ts @@ -0,0 +1,248 @@ +/** + * Beszel Fleet Collector + * Fetches system metrics from Beszel hub (PocketBase API) and stores in tbl_eal_monitoring. + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { z } from 'zod/v4' +import db from '../db.js' + +// ── Zod schemas for Beszel API responses ──────────────────────────────────── + +const BeszelSystemInfo = z.object({ + cpu: z.number().default(0), + mp: z.number().default(0), + dp: z.number().default(0), + ct: z.number().default(0), + la: z.number().default(0), + tu: z.number().default(0), +}).default({ cpu: 0, mp: 0, dp: 0, ct: 0, la: 0, tu: 0 }) +const BeszelSystem = z.object({ + id: z.string(), + name: z.string(), + host: z.string(), + port: z.number().default(45876), + status: z.string().default('unknown'), + info: BeszelSystemInfo, + created: z.string().optional(), + updated: z.string().optional(), +}) + +const BeszelListResponse = z.object({ + items: z.array(BeszelSystem).default([]), + totalItems: z.number().default(0), + page: z.number().default(1), + perPage: z.number().default(30), + totalPages: z.number().default(1), +}) + +const BeszelAuthResponse = z.object({ + token: z.string(), + record: z.object({ + id: z.string(), + email: z.string().optional(), + }).optional(), +}) + +// ── Derived types ─────────────────────────────────────────────────────────── + +type BeszelSystem = z.infer + +export interface FleetSummary { + total: number + up: number + down: number + systems: FleetSystem[] +} + +export interface FleetSystem { + name: string + host: string + status: string + cpu: number + mem: number + disk: number + containers: number + load: number + uptime: number +} + +// ── Config ────────────────────────────────────────────────────────────────── + +const BESZEL_URL = process.env.BESZEL_URL || 'https://descomplicar-beszel.i7zsqo.easypanel.host' +const BESZEL_EMAIL = process.env.BESZEL_EMAIL || 'emanuel@descomplicar.pt' +const BESZEL_PASSWORD = process.env.BESZEL_PASSWORD || 'AcidaOS_26' + +// Machines to ignore (Tor exit nodes that duplicate real machines) +const IGNORE_NAMES = new Set(['black', 'hp', 'kriku']) + +// ── API Client ────────────────────────────────────────────────────────────── + +let cachedToken: string | null = null +let tokenExpiry = 0 + +async function authenticate(): Promise { + if (cachedToken && Date.now() < tokenExpiry) return cachedToken + + const resp = await fetch(`${BESZEL_URL}/api/collections/users/auth-with-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: BESZEL_EMAIL, password: BESZEL_PASSWORD }), + signal: AbortSignal.timeout(10_000), + }) + + if (!resp.ok) throw new Error(`Beszel auth failed: ${resp.status}`) + + const raw = await resp.json() + const parsed = BeszelAuthResponse.safeParse(raw) + if (!parsed.success) throw new Error(`Beszel auth response invalid: ${parsed.error.message}`) + + cachedToken = parsed.data.token + tokenExpiry = Date.now() + 55 * 60 * 1000 // 55 min + return cachedToken +} + +async function fetchSystems(): Promise { + const token = await authenticate() + + const resp = await fetch(`${BESZEL_URL}/api/collections/systems/records?limit=100`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + }) + + if (!resp.ok) throw new Error(`Beszel systems fetch failed: ${resp.status}`) + + const raw = await resp.json() + const parsed = BeszelListResponse.safeParse(raw) + if (!parsed.success) throw new Error(`Beszel systems response invalid: ${parsed.error.message}`) + + return parsed.data.items +} + +// ── Collector ─────────────────────────────────────────────────────────────── + +async function upsertMonitoring(category: string, name: string, status: string, details: Record): Promise { + const detailsJson = JSON.stringify(details) + + const [result] = await db.query( + `UPDATE tbl_eal_monitoring SET status = ?, details = ?, last_check = NOW() WHERE category = ? AND name = ?`, + [status, detailsJson, category, name] + ) + + if ((result as { affectedRows: number }).affectedRows === 0) { + await db.query( + `INSERT INTO tbl_eal_monitoring (category, name, status, details, last_check) VALUES (?, ?, ?, ?, NOW())`, + [category, name, status, detailsJson] + ) + } +} + +/** + * Collect all Beszel fleet systems and store as individual monitoring records. + * Category: 'fleet' + */ +export async function collectBeszelFleet(): Promise { + const systems = await fetchSystems() + const fleet: FleetSystem[] = [] + let upCount = 0 + let downCount = 0 + + for (const sys of systems) { + // Skip known duplicates (Tor exit nodes) + if (IGNORE_NAMES.has(sys.name)) continue + + const info = sys.info + const isUp = sys.status === 'up' + + if (isUp) upCount++ + else downCount++ + + const status = isUp ? 'ok' : 'down' + + await upsertMonitoring('fleet', sys.name, status, { + host: sys.host, + port: sys.port, + cpu: info.cpu, + mem: info.mp, + disk: info.dp, + containers: info.ct, + load: info.la, + uptime: info.tu, + beszel_status: sys.status, + }) + + fleet.push({ + name: sys.name, + host: sys.host, + status: sys.status, + cpu: info.cpu, + mem: info.mp, + disk: info.dp, + containers: info.ct, + load: info.la, + uptime: info.tu, + }) + } + + const total = upCount + downCount + const overallStatus = downCount > 0 ? 'warning' : 'ok' + + await upsertMonitoring('fleet', '_summary', overallStatus, { + total, + up: upCount, + down: downCount, + timestamp: new Date().toISOString(), + }) + + console.log(`[BESZEL] Fleet: ${upCount}/${total} up, ${downCount} down`) + + return { total, up: upCount, down: downCount, systems: fleet } +} + +/** + * Get fleet data from MySQL (already collected). + * Used by the /api/beszel route. + */ +export async function getFleetData(): Promise { + const [rows] = await db.query( + `SELECT name, status, details, last_check FROM tbl_eal_monitoring WHERE category = 'fleet' ORDER BY name` + ) as [{ name: string; status: string; details: string; last_check: string }[], unknown] + + const systems: FleetSystem[] = [] + let summary = { total: 0, up: 0, down: 0 } + + for (const row of rows) { + if (row.name === '_summary') { + try { + const d = JSON.parse(row.details) as Record + summary = { + total: Number(d.total) || 0, + up: Number(d.up) || 0, + down: Number(d.down) || 0, + } + } catch { /* ignore parse errors */ } + continue + } + + try { + const d = JSON.parse(row.details) as Record + systems.push({ + name: row.name, + host: String(d.host || ''), + status: row.status === 'ok' ? 'up' : 'down', + cpu: Number(d.cpu) || 0, + mem: Number(d.mem) || 0, + disk: Number(d.disk) || 0, + containers: Number(d.containers) || 0, + load: Number(d.load) || 0, + uptime: Number(d.uptime) || 0, + }) + } catch { /* skip corrupted rows */ } + } + + return { + total: summary.total || systems.length, + up: summary.up || systems.filter(s => s.status === 'up').length, + down: summary.down || systems.filter(s => s.status !== 'up').length, + systems, + } +} diff --git a/src/pages/Monitor.tsx b/src/pages/Monitor.tsx index b5913ad..e259a12 100644 --- a/src/pages/Monitor.tsx +++ b/src/pages/Monitor.tsx @@ -18,6 +18,7 @@ import { Cpu, MemoryStick, MonitorDot, + Laptop2, } from 'lucide-react' // Types @@ -60,6 +61,24 @@ interface VMConfig { accentBg: string accentBorder: string } +interface FleetSystem { + name: string + host: string + status: string + cpu: number + mem: number + disk: number + containers: number + load: number + uptime: number +} + +interface FleetData { + total: number + up: number + down: number + systems: FleetSystem[] +} // VM definitions matching cluster architecture const VM_CONFIG: Record = { @@ -331,16 +350,25 @@ const VMCard = ({ item, config }: { item?: MonitorItem; config: VMConfig }) => { export default function Monitor() { const [data, setData] = useState(null) + const [fleet, setFleet] = useState(null) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const fetchData = useCallback(async () => { setRefreshing(true) try { - const response = await fetch('/api/monitor') - if (!response.ok) throw new Error('Failed') - const json = await response.json() - setData(json) + const [monitorRes, fleetRes] = await Promise.all([ + fetch('/api/monitor'), + fetch('/api/beszel'), + ]) + if (monitorRes.ok) { + const json = await monitorRes.json() + setData(json) + } + if (fleetRes.ok) { + const json = await fleetRes.json() + setFleet(json) + } } catch { if (import.meta.env.DEV) { setData(getMockData()) @@ -421,6 +449,61 @@ export default function Monitor() { ))} + {/* Section 2.5: Fleet Beszel */} + {fleet && fleet.systems.length > 0 && ( + +
+
+

+ + Fleet — {fleet.up}/{fleet.total} activas +

+
+ {fleet.down > 0 && ( + {fleet.down} down + )} + + Abrir Beszel → + +
+
+
+
+ {fleet.systems.sort((a, b) => a.name.localeCompare(b.name)).map((sys) => ( +
+
+ + {sys.name} +
+
+ +
+ RAM {sys.mem}% + DK {sys.disk}% +
+ {sys.containers > 0 && ( +
{sys.containers} containers
+ )} +
+
+ ))} +
+
+
+
+ )} {/* Section 3: Detail Categories */}
From 485a4ee67ccb792ce2b4e11f2853e399c4452213 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 23 Jun 2026 20:30:05 +0100 Subject: [PATCH 3/5] fix(beszel): increase API timeouts to 30s (PocketBase auth is slow) --- api/services/beszel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/services/beszel.ts b/api/services/beszel.ts index 45f6193..3c827cd 100644 --- a/api/services/beszel.ts +++ b/api/services/beszel.ts @@ -87,7 +87,7 @@ async function authenticate(): Promise { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identity: BESZEL_EMAIL, password: BESZEL_PASSWORD }), - signal: AbortSignal.timeout(10_000), + signal: AbortSignal.timeout(30_000), }) if (!resp.ok) throw new Error(`Beszel auth failed: ${resp.status}`) @@ -106,7 +106,7 @@ async function fetchSystems(): Promise { const resp = await fetch(`${BESZEL_URL}/api/collections/systems/records?limit=100`, { headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.timeout(30_000), }) if (!resp.ok) throw new Error(`Beszel systems fetch failed: ${resp.status}`) From 7ac01d5a82c1685323099368da884742a77b18ed Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Tue, 23 Jun 2026 20:33:30 +0100 Subject: [PATCH 4/5] fix(beszel): coerce port to number, handle la array from PocketBase --- api/services/beszel.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/services/beszel.ts b/api/services/beszel.ts index 3c827cd..e742391 100644 --- a/api/services/beszel.ts +++ b/api/services/beszel.ts @@ -9,18 +9,18 @@ import db from '../db.js' // ── Zod schemas for Beszel API responses ──────────────────────────────────── const BeszelSystemInfo = z.object({ - cpu: z.number().default(0), - mp: z.number().default(0), - dp: z.number().default(0), - ct: z.number().default(0), - la: z.number().default(0), - tu: z.number().default(0), + cpu: z.coerce.number().default(0), + mp: z.coerce.number().default(0), + dp: z.coerce.number().default(0), + ct: z.coerce.number().default(0), + la: z.union([z.number(), z.array(z.number())]).transform(v => Array.isArray(v) ? v[0] || 0 : v).default(0), + tu: z.coerce.number().default(0), }).default({ cpu: 0, mp: 0, dp: 0, ct: 0, la: 0, tu: 0 }) const BeszelSystem = z.object({ id: z.string(), name: z.string(), host: z.string(), - port: z.number().default(45876), + port: z.coerce.number().default(45876), status: z.string().default('unknown'), info: BeszelSystemInfo, created: z.string().optional(), From f7acd99d8fece817517c7e6fe4ed06d1eab5bc11 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Sun, 28 Jun 2026 22:58:21 +0100 Subject: [PATCH 5/5] docs(okf): frontmatter OKF + rich abstracts nas descriptions Normalizacao OKF dos .md: type/title/description/timestamp/layer + descriptions factuais (rich abstracts). Apenas .md tracked; corpos intactos. Parte da aplicacao OKF a /Dados/Dev (28-06-2026). Co-Authored-By: Claude Opus 4.8 (1M context) --- AUDIT-REPORT.md | 8 ++++++++ CHANGELOG.md | 8 ++++++++ README.md | 8 ++++++++ SPEC.md | 8 ++++++++ api/README.md | 8 ++++++++ docs/PLAN-conductor-dashboard-expansion.md | 3 +++ docs/SPEC-dashboard-expansion-q2-2026.md | 3 +++ 7 files changed, 46 insertions(+) diff --git a/AUDIT-REPORT.md b/AUDIT-REPORT.md index b7d82e6..531f83f 100644 --- a/AUDIT-REPORT.md +++ b/AUDIT-REPORT.md @@ -1,3 +1,11 @@ +--- +type: Document +title: Audit Report +description: >- + Relatório de auditoria de segurança e qualidade do DashDescomplicar (React 19 + TypeScript + Vite + Express + MySQL), com 20 vulnerabilidades classificadas e veredicto de não apto para produção. +timestamp: 2026-02-14T03:33:42.730555+00:00 +layer: wiki +--- # Auditoria de Segurança e Qualidade - DashDescomplicar **Data:** 14-02-2026 diff --git a/CHANGELOG.md b/CHANGELOG.md index 46858ca..3a6f909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +--- +type: Changelog +title: Changelog +description: >- + Todas as alterações notáveis neste projecto serão documentadas neste ficheiro +timestamp: 2026-06-15T18:43:39.495926+00:00 +layer: wiki +--- # Changelog Todas as alterações notáveis neste projecto serão documentadas neste ficheiro. diff --git a/README.md b/README.md index 05f8f82..24f4c77 100755 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +--- +type: Reference +title: Readme +description: >- + > Dashboard de Gestão e Monitorização da Descomplicar® +timestamp: 2026-02-14T04:22:01.943892+00:00 +layer: wiki +--- # DashDescomplicar > **Dashboard de Gestão e Monitorização** da Descomplicar® diff --git a/SPEC.md b/SPEC.md index 75bbdc2..d57388b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,3 +1,11 @@ +--- +type: Specification +title: Spec +description: >- + Especificação do projecto DashDescomplicar, agregando os projectos activos — nomeadamente o painel Observabilidade (replay de sessões Claude Code com indexer, SQLite, API e UI timeline). +timestamp: 2026-04-27T10:15:35.733200+00:00 +layer: wiki +--- # SPEC — DashDescomplicar diff --git a/api/README.md b/api/README.md index 964b2ad..a6b226f 100755 --- a/api/README.md +++ b/api/README.md @@ -1,3 +1,11 @@ +--- +type: Reference +title: Readme +description: >- + API Node.js/Express com queries diretas à BD Desk CRM +timestamp: 2026-02-04T03:23:33.606360+00:00 +layer: wiki +--- # Dashboard Descomplicar API API Node.js/Express com queries diretas à BD Desk CRM. diff --git a/docs/PLAN-conductor-dashboard-expansion.md b/docs/PLAN-conductor-dashboard-expansion.md index 123fc07..5c6ccc8 100644 --- a/docs/PLAN-conductor-dashboard-expansion.md +++ b/docs/PLAN-conductor-dashboard-expansion.md @@ -1,10 +1,13 @@ --- title: PLAN Conductor — Expansão DashDescomplicar +description: >- + Spec: `docs/SPEC-dashboard-expansion-q2-2026.md` date: 2026-04-06 type: plan status: active spec: SPEC-dashboard-expansion-q2-2026.md tags: [conductor, plan, dashboard, parallel] +layer: wiki --- # Plano Conductor — Expansão DashDescomplicar diff --git a/docs/SPEC-dashboard-expansion-q2-2026.md b/docs/SPEC-dashboard-expansion-q2-2026.md index 03d14e0..73a9914 100644 --- a/docs/SPEC-dashboard-expansion-q2-2026.md +++ b/docs/SPEC-dashboard-expansion-q2-2026.md @@ -1,10 +1,13 @@ --- title: SPEC — Expansão DashDescomplicar Q2 2026 +description: >- + O DashDescomplicar é o painel de gestão interno da Descomplicar®. Stack: React 19 + Vite + Tailwind CSS 4 + Express 4 + … date: 2026-04-06 type: spec status: active desk_project: 65 tags: [dashboard, expansion, mcps, paperclip, n8n, claude-code] +layer: wiki --- # SPEC — Expansão DashDescomplicar Q2 2026