[31188f42] einfügen

einfügen
This commit is contained in:
2026-02-24 08:40:38 +00:00
parent fa1ee24315
commit 0fd67ecc91
9 changed files with 361 additions and 65 deletions

View File

@@ -1 +1 @@
{"task_id": "2ff88f42-8544-8050-8245-c3bb852058f4", "token": "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", "session_start_time": "2026-02-24T07:13:35.422817"} {"task_id": "31188f42-8544-806d-bd3c-e45991a3e8c8", "token": "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", "session_start_time": "2026-02-24T08:40:20.490166"}

View File

@@ -1252,7 +1252,6 @@ def run_batch_classification_task():
# --- Serve Frontend --- # --- Serve Frontend ---
static_path = "/frontend_static" static_path = "/frontend_static"
if not os.path.exists(static_path): if not os.path.exists(static_path):
# Local dev fallback
static_path = os.path.join(os.path.dirname(__file__), "../../frontend/dist") static_path = os.path.join(os.path.dirname(__file__), "../../frontend/dist")
if not os.path.exists(static_path): if not os.path.exists(static_path):
static_path = os.path.join(os.path.dirname(__file__), "../static") static_path = os.path.join(os.path.dirname(__file__), "../static")
@@ -1260,11 +1259,34 @@ if not os.path.exists(static_path):
logger.info(f"Static files path: {static_path} (Exists: {os.path.exists(static_path)})") logger.info(f"Static files path: {static_path} (Exists: {os.path.exists(static_path)})")
if os.path.exists(static_path): if os.path.exists(static_path):
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
index_file = os.path.join(static_path, "index.html")
# Mount assets specifically first
assets_path = os.path.join(static_path, "assets")
if os.path.exists(assets_path):
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
@app.get("/") @app.get("/")
async def serve_index(): async def serve_index():
return FileResponse(os.path.join(static_path, "index.html")) return FileResponse(index_file)
app.mount("/", StaticFiles(directory=static_path, html=True), name="static") # Catch-all for SPA routing (any path not matched by API or assets)
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
# Allow API calls to fail naturally with 404
if full_path.startswith("api/"):
raise HTTPException(status_code=404)
# If it's a file that exists, serve it (e.g. favicon, robots.txt)
file_path = os.path.join(static_path, full_path)
if os.path.isfile(file_path):
return FileResponse(file_path)
# Otherwise, serve index.html for SPA routing
return FileResponse(index_file)
else: else:
@app.get("/") @app.get("/")
def root_no_frontend(): def root_no_frontend():

View File

@@ -4,7 +4,7 @@ import { ContactsTable } from './components/ContactsTable' // NEW
import { ImportWizard } from './components/ImportWizard' import { ImportWizard } from './components/ImportWizard'
import { Inspector } from './components/Inspector' import { Inspector } from './components/Inspector'
import { RoboticsSettings } from './components/RoboticsSettings' import { RoboticsSettings } from './components/RoboticsSettings'
import { LayoutDashboard, UploadCloud, RefreshCw, Settings, Users, Building, Sun, Moon } from 'lucide-react' import { LayoutDashboard, UploadCloud, RefreshCw, Settings, Users, Building, Sun, Moon, Activity } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
// Base URL detection (Production vs Dev) // Base URL detection (Production vs Dev)
@@ -119,6 +119,16 @@ function App() {
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />} {theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button> </button>
<a
href="/connector/dashboard"
target="_blank"
rel="noopener noreferrer"
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400"
title="Connector Status Dashboard"
>
<Activity className="h-5 w-5" />
</a>
<button <button
onClick={() => setIsSettingsOpen(true)} onClick={() => setIsSettingsOpen(true)}
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400" className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400"

View File

@@ -100,7 +100,27 @@ Der Connector ist der Bote, der diese Daten in das CRM bringt.
--- ---
## 6. Setup & Wartung ### 6. Monitoring & Dashboard ("The Eyes")
Das System verfügt über ein integriertes Echtzeit-Dashboard zur Überwachung der Synchronisationsprozesse.
**Features:**
* **Account-basierte Ansicht:** Gruppiert alle Ereignisse nach SuperOffice-Account oder Person, um den aktuellen Status pro Datensatz zu zeigen.
* **Phasen-Visualisierung:** Stellt den Fortschritt in vier Phasen dar:
1. **Received:** Webhook erfolgreich empfangen.
2. **Enriching:** Datenanreicherung im Company Explorer läuft (Gelb blinkend = In Arbeit).
3. **Syncing:** Rückschreiben der Daten nach SuperOffice (Gelb blinkend = In Arbeit).
4. **Completed:** Prozess für diesen Kontakt erfolgreich abgeschlossen (Grün).
* **Performance-Tracking:** Anzeige der Gesamtdurchlaufzeit (Duration) pro Prozess.
* **Fehler-Analyse:** Detaillierte Fehlermeldungen direkt in der Übersicht.
* **Dark Mode:** Modernes UI-Design für Admin-Monitoring.
**Zugriff:**
Das Dashboard ist über das Company Explorer Frontend (Icon "Activity" im Header) oder direkt unter `/connector/dashboard` erreichbar.
---
## 7. Setup & Wartung
### Neue Branche hinzufügen ### Neue Branche hinzufügen
1. In **Notion** anlegen (Pains/Gains/Produkte definieren). 1. In **Notion** anlegen (Pains/Gains/Produkte definieren).

View File

@@ -77,13 +77,19 @@ class JobQueue:
return job return job
def retry_job_later(self, job_id, delay_seconds=60): def retry_job_later(self, job_id, delay_seconds=60, error_msg=None):
next_try = datetime.utcnow() + timedelta(seconds=delay_seconds) next_try = datetime.utcnow() + timedelta(seconds=delay_seconds)
with sqlite3.connect(DB_PATH) as conn: with sqlite3.connect(DB_PATH) as conn:
conn.execute( if error_msg:
"UPDATE jobs SET status = 'PENDING', next_try_at = ?, updated_at = datetime('now') WHERE id = ?", conn.execute(
(next_try, job_id) "UPDATE jobs SET status = 'PENDING', next_try_at = ?, updated_at = datetime('now'), error_msg = ? WHERE id = ?",
) (next_try, str(error_msg), job_id)
)
else:
conn.execute(
"UPDATE jobs SET status = 'PENDING', next_try_at = ?, updated_at = datetime('now') WHERE id = ?",
(next_try, job_id)
)
def complete_job(self, job_id): def complete_job(self, job_id):
with sqlite3.connect(DB_PATH) as conn: with sqlite3.connect(DB_PATH) as conn:
@@ -125,3 +131,113 @@ class JobQueue:
pass pass
results.append(r) results.append(r)
return results return results
def get_account_summary(self, limit=1000):
"""
Groups recent jobs by ContactId/PersonId and returns a summary status.
"""
jobs = self.get_recent_jobs(limit=limit)
accounts = {}
for job in jobs:
payload = job.get('payload', {})
# Try to find IDs
c_id = payload.get('ContactId')
p_id = payload.get('PersonId')
# Fallback for cascaded jobs or primary keys
if not c_id and payload.get('PrimaryKey') and 'contact' in job['event_type'].lower():
c_id = payload.get('PrimaryKey')
if not p_id and payload.get('PrimaryKey') and 'person' in job['event_type'].lower():
p_id = payload.get('PrimaryKey')
if not c_id and not p_id:
continue
# Create a unique key for the entity
key = f"P{p_id}" if p_id else f"C{c_id}"
if key not in accounts:
accounts[key] = {
"id": key,
"contact_id": c_id,
"person_id": p_id,
"name": "Unknown",
"last_event": job['event_type'],
"status": job['status'],
"created_at": job['created_at'], # Oldest job in group (since we sort by DESC)
"updated_at": job['updated_at'], # Most recent job
"error_msg": job['error_msg'],
"job_count": 0,
"duration": "0s",
"phases": {
"received": "completed",
"enriching": "pending",
"syncing": "pending",
"completed": "pending"
}
}
acc = accounts[key]
acc["job_count"] += 1
# Update duration
try:
# We want the absolute start (oldest created_at)
# Since jobs are DESC, the last one we iterate through for a key is the oldest
acc["created_at"] = job["created_at"]
start = datetime.strptime(acc["created_at"], "%Y-%m-%d %H:%M:%S")
end = datetime.strptime(acc["updated_at"], "%Y-%m-%d %H:%M:%S")
diff = end - start
seconds = int(diff.total_seconds())
if seconds < 60:
acc["duration"] = f"{seconds}s"
else:
acc["duration"] = f"{seconds // 60}m {seconds % 60}s"
except Exception:
pass
# Try to resolve 'Unknown' name from any job in the group
if acc["name"] == "Unknown":
name = payload.get('Name') or payload.get('crm_name') or payload.get('FullName') or payload.get('ContactName')
if not name and payload.get('Firstname'):
name = f"{payload.get('Firstname')} {payload.get('Lastname', '')}".strip()
if name:
acc["name"] = name
# Update overall status based on most recent job
# (Assuming jobs are sorted by updated_at DESC)
if acc["job_count"] == 1:
acc["status"] = job["status"]
acc["updated_at"] = job["updated_at"]
acc["error_msg"] = job["error_msg"]
# Determine Phase
if job["status"] == "COMPLETED":
acc["phases"] = {
"received": "completed",
"enriching": "completed",
"syncing": "completed",
"completed": "completed"
}
elif job["status"] == "FAILED":
acc["phases"]["received"] = "completed"
acc["phases"]["enriching"] = "failed"
elif job["status"] == "PROCESSING":
acc["phases"]["received"] = "completed"
acc["phases"]["enriching"] = "processing"
elif job["status"] == "PENDING":
acc["phases"]["received"] = "completed"
# If it has an error msg like 'processing', it's in enriching
if job["error_msg"] and "processing" in job["error_msg"].lower():
acc["phases"]["enriching"] = "processing"
else:
acc["phases"]["received"] = "processing"
# Final cleanup for names
for acc in accounts.values():
if acc["name"] == "Unknown":
acc["name"] = f"Entity {acc['id']}"
return list(accounts.values())

View File

@@ -56,6 +56,10 @@ def stats():
def get_jobs(): def get_jobs():
return queue.get_recent_jobs(limit=100) return queue.get_recent_jobs(limit=100)
@app.get("/api/accounts")
def get_accounts():
return queue.get_account_summary(limit=500)
@app.get("/dashboard", response_class=HTMLResponse) @app.get("/dashboard", response_class=HTMLResponse)
def dashboard(): def dashboard():
html_content = """ html_content = """
@@ -63,70 +67,188 @@ def dashboard():
<html> <html>
<head> <head>
<title>Connector Dashboard</title> <title>Connector Dashboard</title>
<meta http-equiv="refresh" content="5"> <meta http-equiv="refresh" content="30">
<style> <style>
body { font-family: sans-serif; padding: 20px; background: #f0f2f5; } body {
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
h1 { color: #333; } padding: 20px;
table { width: 100%; border-collapse: collapse; margin-top: 20px; } background: #0f172a;
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #ddd; font-size: 14px; } color: #f1f5f9;
th { background-color: #f8f9fa; color: #666; font-weight: 600; } }
tr:hover { background-color: #f8f9fa; } .container {
.status { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; text-transform: uppercase; } max-width: 1200px;
.status-PENDING { background: #e2e8f0; color: #475569; } margin: 0 auto;
.status-PROCESSING { background: #dbeafe; color: #1e40af; } background: #1e293b;
.status-COMPLETED { background: #dcfce7; color: #166534; } padding: 24px;
.status-FAILED { background: #fee2e2; color: #991b1b; } border-radius: 12px;
.status-RETRY { background: #fef9c3; color: #854d0e; } box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
.meta { color: #888; font-size: 12px; } border: 1px solid #334155;
pre { margin: 0; white-space: pre-wrap; word-break: break-word; color: #444; font-family: monospace; font-size: 11px; max-height: 60px; overflow-y: auto; } }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
h1 { margin: 0; font-size: 24px; color: #f8fafc; }
.tabs { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid #334155; padding-bottom: 10px; }
.tab { padding: 8px 16px; cursor: pointer; border-radius: 6px; font-weight: 500; font-size: 14px; color: #94a3b8; transition: all 0.2s; }
.tab:hover { background: #334155; color: #f8fafc; }
.tab.active { background: #3b82f6; color: white; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 14px; border-bottom: 1px solid #334155; font-size: 14px; }
th { background-color: #1e293b; color: #94a3b8; font-weight: 600; text-transform: uppercase; font-size: 12px; letter-spacing: 0.5px; }
tr:hover { background-color: #334155; }
.status { padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.status-PENDING { background: #334155; color: #cbd5e1; }
.status-PROCESSING { background: #1e40af; color: #bfdbfe; }
.status-COMPLETED { background: #064e3b; color: #a7f3d0; }
.status-FAILED { background: #7f1d1d; color: #fecaca; }
.phases { display: flex; gap: 4px; align-items: center; }
.phase { width: 12px; height: 12px; border-radius: 50%; background: #334155; border: 2px solid #1e293b; box-shadow: 0 0 0 1px #334155; }
.phase.completed { background: #10b981; box-shadow: 0 0 0 1px #10b981; }
.phase.processing { background: #f59e0b; box-shadow: 0 0 0 1px #f59e0b; animation: pulse 1.5s infinite; }
.phase.failed { background: #ef4444; box-shadow: 0 0 0 1px #ef4444; }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
.meta { color: #94a3b8; font-size: 12px; display: block; margin-top: 4px; }
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #cbd5e1;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
max-height: 80px;
overflow-y: auto;
background: #0f172a;
padding: 10px;
border-radius: 6px;
border: 1px solid #334155;
}
.hidden { display: none; }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div style="display: flex; justify-content: space-between; align-items: center;"> <header>
<h1>🔌 SuperOffice Connector Dashboard</h1> <h1>🔌 SuperOffice Connector Dashboard</h1>
<div id="stats"></div> <div id="stats"></div>
</header>
<div class="tabs">
<div class="tab active" id="tab-accounts" onclick="switchTab('accounts')">Account View</div>
<div class="tab" id="tab-events" onclick="switchTab('events')">Event Log</div>
</div> </div>
<table> <div id="view-accounts">
<thead> <table>
<tr> <thead>
<th width="50">ID</th> <tr>
<th width="120">Status</th> <th>Account / Person</th>
<th width="150">Updated</th> <th width="120">ID</th>
<th width="150">Event</th> <th width="150">Process Progress</th>
<th>Payload / Error</th> <th width="100">Duration</th>
</tr> <th width="120">Status</th>
</thead> <th width="150">Last Update</th>
<tbody id="job-table"> <th>Details</th>
<tr><td colspan="5" style="text-align:center;">Loading...</td></tr> </tr>
</tbody> </thead>
</table> <tbody id="account-table">
<tr><td colspan="6" style="text-align:center;">Loading Accounts...</td></tr>
</tbody>
</table>
</div>
<div id="view-events" class="hidden">
<table>
<thead>
<tr>
<th width="50">ID</th>
<th width="120">Status</th>
<th width="150">Updated</th>
<th width="150">Event</th>
<th>Payload / Error</th>
</tr>
</thead>
<tbody id="event-table">
<tr><td colspan="5" style="text-align:center;">Loading Events...</td></tr>
</tbody>
</table>
</div>
</div> </div>
<script> <script>
let currentTab = 'accounts';
function switchTab(tab) {
currentTab = tab;
document.getElementById('tab-accounts').classList.toggle('active', tab === 'accounts');
document.getElementById('tab-events').classList.toggle('active', tab === 'events');
document.getElementById('view-accounts').classList.toggle('hidden', tab !== 'accounts');
document.getElementById('view-events').classList.toggle('hidden', tab !== 'events');
loadData();
}
async function loadData() { async function loadData() {
if (currentTab === 'accounts') await loadAccounts();
else await loadEvents();
}
async function loadAccounts() {
try { try {
// Use relative path to work behind Nginx /connector/ prefix const response = await fetch('api/accounts');
const response = await fetch('api/jobs'); const accounts = await response.json();
const jobs = await response.json(); const tbody = document.getElementById('account-table');
const tbody = document.getElementById('job-table');
tbody.innerHTML = ''; tbody.innerHTML = '';
if (jobs.length === 0) { if (accounts.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;">No jobs found</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;">No accounts in process</td></tr>';
return; return;
} }
accounts.sort((a,b) => new Date(b.updated_at) - new Date(a.updated_at));
accounts.forEach(acc => {
const tr = document.createElement('tr');
const phasesHtml = `
<div class="phases">
<div class="phase ${acc.phases.received}" title="Received"></div>
<div class="phase ${acc.phases.enriching}" title="Enriching (CE)"></div>
<div class="phase ${acc.phases.syncing}" title="Syncing (SO)"></div>
<div class="phase ${acc.phases.completed}" title="Completed"></div>
</div>
`;
tr.innerHTML = `
<td>
<strong>${acc.name}</strong>
<span class="meta">${acc.last_event}</span>
</td>
<td>${acc.id}</td>
<td>${phasesHtml}</td>
<td><span class="meta">${acc.duration || '0s'}</span></td>
<td><span class="status status-${acc.status}">${acc.status}</span></td>
<td>${new Date(acc.updated_at + "Z").toLocaleTimeString()}</td>
<td><pre>${acc.error_msg || 'No issues'}</pre></td>
`;
tbody.appendChild(tr);
});
} catch (e) { console.error("Failed to load accounts", e); }
}
async function loadEvents() {
try {
const response = await fetch('api/jobs');
const jobs = await response.json();
const tbody = document.getElementById('event-table');
tbody.innerHTML = '';
jobs.forEach(job => { jobs.forEach(job => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
let details = JSON.stringify(job.payload, null, 2); let details = JSON.stringify(job.payload, null, 2);
if (job.error_msg) { if (job.error_msg) details += "\\n\\n🔴 ERROR: " + job.error_msg;
details += "\\n\\n🔴 ERROR: " + job.error_msg;
}
tr.innerHTML = ` tr.innerHTML = `
<td>#${job.id}</td> <td>#${job.id}</td>
@@ -137,13 +259,11 @@ def dashboard():
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} catch (e) { } catch (e) { console.error("Failed to load events", e); }
console.error("Failed to load jobs", e);
}
} }
loadData(); loadData();
// Also handled by meta refresh, but JS refresh is smoother if we want to remove meta refresh setInterval(loadData, 5000);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -391,7 +391,9 @@ def run_worker():
try: try:
result = process_job(job, so_client) result = process_job(job, so_client)
if result == "RETRY": if result == "RETRY":
queue.retry_job_later(job['id'], delay_seconds=120) queue.retry_job_later(job['id'], delay_seconds=120, error_msg="CE is processing...")
elif result == "FAILED":
queue.fail_job(job['id'], "Job failed with FAILED status")
else: else:
queue.complete_job(job['id']) queue.complete_job(job['id'])
except Exception as e: except Exception as e:

13
debug_paths.py Normal file
View File

@@ -0,0 +1,13 @@
import os
static_path = "/frontend_static"
print(f"Path {static_path} exists: {os.path.exists(static_path)}")
if os.path.exists(static_path):
for root, dirs, files in os.walk(static_path):
for file in files:
print(os.path.join(root, file))
else:
print("Listing /app instead:")
for root, dirs, files in os.walk("/app"):
if "node_modules" in root: continue
for file in files:
print(os.path.join(root, file))

View File

@@ -169,13 +169,6 @@ http {
location /connector/ { location /connector/ {
# SuperOffice Connector Webhook & Dashboard # SuperOffice Connector Webhook & Dashboard
# Auth enabled for dashboard access (webhook endpoint might need exclusion if public,
# but current webhook_app checks token param so maybe basic auth is fine for /dashboard?)
# For now, let's keep it open or use token.
# Ideally: /connector/webhook -> open, /connector/dashboard -> protected.
# Nginx doesn't support nested locations well for auth_basic override without duplicating.
# Simplified: Auth off globally for /connector/, rely on App logic or obscurity for now.
auth_basic off; auth_basic off;
# Forward to FastAPI app # Forward to FastAPI app