Commit 01a94bb3 authored by Admin's avatar Admin

feat: Add Autonomous Company Org Chart UI and Plugin Sync

parent 483c835b
...@@ -180,46 +180,11 @@ async def get_plugins_registry( ...@@ -180,46 +180,11 @@ async def get_plugins_registry(
if not membership: if not membership:
raise HTTPException(status_code=403, detail="No access to company") raise HTTPException(status_code=403, detail="No access to company")
# Define standard plugins
STANDARD_PLUGINS = {
"trello": "Trello integration for project management",
"notion": "Notion integration for document syncing",
"linear": "Linear integration for engineering task tracking",
"clickup": "ClickUp integration for project management",
"github_projects": "GitHub Projects integration for task management",
"facebook_pages": "Facebook Page posting integration",
"nocobase_erp": "NocoBase ERP connection for structured data",
"slack": "Slack realtime notifications integration",
"langfuse": "Langfuse tracing and observability integration",
"google_drive": "Google Drive cloud storage integration",
"gemini_content": "Gemini API content generation",
"image_gen": "AI image generation for product photos"
}
# Retrieve existing plugins from DB # Retrieve existing plugins from DB
existing_plugins_stmt = select(Plugin) existing_plugins_stmt = select(Plugin)
existing_plugins_res = await db.execute(existing_plugins_stmt) existing_plugins_res = await db.execute(existing_plugins_stmt)
existing_plugins = {p.name: p for p in existing_plugins_res.scalars().all()} existing_plugins = {p.name: p for p in existing_plugins_res.scalars().all()}
# Auto-seed standard plugins if missing
db_changed = False
for slug, desc in STANDARD_PLUGINS.items():
if slug not in existing_plugins:
new_p = Plugin(
id=f"plugin_{slug}",
name=slug,
version="1.0.0",
description=desc,
enabled=True,
config_schema={}
)
db.add(new_p)
existing_plugins[slug] = new_p
db_changed = True
if db_changed:
await db.flush()
# Get active configs for this company # Get active configs for this company
configs_stmt = select(PluginConfig).where( configs_stmt = select(PluginConfig).where(
PluginConfig.company_id == company_id, PluginConfig.company_id == company_id,
...@@ -240,8 +205,5 @@ async def get_plugins_registry( ...@@ -240,8 +205,5 @@ async def get_plugins_registry(
"description": p.description "description": p.description
}) })
if db_changed:
await db.commit()
return registry_list return registry_list
...@@ -42,6 +42,9 @@ if not os.path.exists(STATIC_DIR): ...@@ -42,6 +42,9 @@ if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR) os.makedirs(STATIC_DIR)
logger.info("Static dir resolved: %s", STATIC_DIR) logger.info("Static dir resolved: %s", STATIC_DIR)
# UI Prototype directory (ui-prototype/ at project root)
UI_PROTO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "ui-prototype")
# ===================================================================== # =====================================================================
# LIFESPAN — Graceful startup & shutdown # LIFESPAN — Graceful startup & shutdown
...@@ -64,6 +67,16 @@ async def lifespan(app: FastAPI): ...@@ -64,6 +67,16 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to start MCP clients: {e}", exc_info=True) logger.error(f"❌ Failed to start MCP clients: {e}", exc_info=True)
# Sync Local Plugins
try:
from services.plugin_manager import PluginManager
from database import get_db_session
async with get_db_session() as session:
logger.info("📦 Syncing plugins from local marketplace...")
await PluginManager.sync_plugins(session)
except Exception as e:
logger.error(f"❌ Failed to sync plugins: {e}", exc_info=True)
# Start Heartbeat Scheduler Loop if enabled (fallback/primary scheduler when Celery Beat is not active) # Start Heartbeat Scheduler Loop if enabled (fallback/primary scheduler when Celery Beat is not active)
if settings.heartbeat_scheduler_enabled: if settings.heartbeat_scheduler_enabled:
async def scheduler_loop(): async def scheduler_loop():
...@@ -344,6 +357,27 @@ async def serve_root_files_fallback(file_name: str): ...@@ -344,6 +357,27 @@ async def serve_root_files_fallback(file_name: str):
return FileResponse(full_path) return FileResponse(full_path)
return JSONResponse({"detail": "Not Found"}, status_code=404) return JSONResponse({"detail": "Not Found"}, status_code=404)
@app.get("/proto")
@app.get("/proto/")
async def serve_proto_index():
"""Serve the HTML/CSS/JS UI prototype."""
idx = os.path.join(UI_PROTO_DIR, "index.html")
if os.path.isfile(idx):
return FileResponse(idx)
return JSONResponse({"detail": "ui-prototype/index.html not found"}, status_code=404)
@app.get("/proto/{file_path:path}")
async def serve_proto_files(file_path: str):
"""Serve static assets for the UI prototype."""
full_path = os.path.realpath(os.path.join(UI_PROTO_DIR, file_path))
# Security: must stay within UI_PROTO_DIR
if not full_path.startswith(os.path.realpath(UI_PROTO_DIR)):
return JSONResponse({"detail": "Forbidden"}, status_code=403)
if os.path.isfile(full_path):
return FileResponse(full_path)
return JSONResponse({"detail": "Not Found"}, status_code=404)
@app.get("/home/{file_path:path}") @app.get("/home/{file_path:path}")
async def serve_home_static(file_path: str): async def serve_home_static(file_path: str):
"""Serve home dashboard explicitly under /home namespace.""" """Serve home dashboard explicitly under /home namespace."""
......
"""
Plugin Manager Service
Scans the local knowledge-work-plugins directory to sync available plugins to the database.
"""
import os
import json
import logging
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models import Plugin
logger = logging.getLogger(__name__)
# Default path to the anthropic plugins repo
PLUGINS_DIR = Path(__file__).resolve().parent.parent.parent / "outsource" / "knowledge-work-plugins"
class PluginManager:
@staticmethod
async def sync_plugins(db: AsyncSession):
"""Scan the PLUGINS_DIR and upsert all plugins into the database."""
if not PLUGINS_DIR.exists():
logger.warning(f"Plugins directory not found at {PLUGINS_DIR}. Skipping sync.")
return
plugin_dirs = [d for d in PLUGINS_DIR.iterdir() if d.is_dir() and not d.name.startswith('.')]
synced_count = 0
for pdir in plugin_dirs:
manifest_path = pdir / ".claude-plugin" / "plugin.json"
if not manifest_path.exists():
continue
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
name = manifest.get('name', pdir.name)
version = manifest.get('version', '1.0.0')
description = manifest.get('description', '')
# Check if exists
stmt = select(Plugin).where(Plugin.name == name)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
existing.version = version
existing.description = description
else:
new_plugin = Plugin(
name=name,
version=version,
description=description,
enabled=True
)
db.add(new_plugin)
synced_count += 1
except Exception as e:
logger.error(f"Failed to sync plugin {pdir.name}: {e}")
await db.commit()
logger.info(f"Successfully synced {synced_count} plugins from local marketplace.")
@staticmethod
def get_plugin_skills(plugin_name: str) -> list[dict]:
"""Read all markdown files in the skills/ directory of a plugin."""
skills_dir = PLUGINS_DIR / plugin_name / "skills"
if not skills_dir.exists():
return []
skills = []
for file in skills_dir.glob("*.md"):
try:
with open(file, 'r', encoding='utf-8') as f:
content = f.read()
skills.append({
"name": file.stem,
"content": content
})
except Exception as e:
logger.error(f"Error reading skill {file.name}: {e}")
return skills
{
"template_name": "E-Commerce SME Base",
"version": "1.0",
"departments": {
"executive": {
"role": "CEO",
"plugin": "core-ceo-agent",
"permissions": ["goal_setting", "budget_allocation"]
},
"marketing": {
"role": "Marketing Manager",
"plugin": "marketing-agent",
"tools": ["facebook_ads", "hubspot_email"]
},
"legal": {
"role": "Legal Compliance",
"plugin": "legal-agent",
"tools": ["document_reviewer"]
}
},
"workflows": [
{
"name": "Content Approval Flow",
"trigger": {
"agent": "marketing",
"event": "content_draft_completed"
},
"action": {
"type": "create_task",
"assignee": "legal",
"task_name": "Review Marketing Content",
"context_payload": "$trigger.payload.content",
"instructions": "Kiểm tra xem nội dung này có vi phạm luật quảng cáo hay không. Nếu có, reject lại cho marketing."
}
},
{
"name": "Legal Review Result",
"trigger": {
"agent": "legal",
"event": "review_completed"
},
"conditions": [
{ "field": "$trigger.payload.status", "operator": "==", "value": "rejected" }
],
"action": {
"type": "create_task",
"assignee": "marketing",
"task_name": "Revise Content",
"context_payload": "$trigger.payload.feedback"
}
}
]
}
# PHÂN TÍCH NGHIỆP VỤ: CÁCH CÁC AGENT GIAO TIẾP & SỬ DỤNG TOOL
Sếp bảo "từ từ thảo luận nghiệp vụ" là quá chuẩn! Đọc cái file README của bộ `productivity` plugin mà sếp vừa gửi, tao nhận ra bọn Anthropic có một triết lý thiết kế **CỰC KỲ KHÁC BIỆT** so với cách code truyền thống.
Dưới đây là lời giải cho 2 câu hỏi của sếp:
## 1. Bọn nó có dùng Logic Hook / JSON Flow không?
**Câu trả lời là: KHÔNG!**
Bọn Anthropic không code cứng một cái Workflow JSON (như kiểu Zapier hay Make.com) để ép thằng Marketing làm xong thì phải chuyển cho thằng Legal. Vì làm thế thì rất gò bó và "bớt thông minh" đi.
**Cách bọn nó làm (Cơ chế Shared Workspace):**
- Bọn nó dùng **File Text (TASKS.md)** làm không gian làm việc chung.
- Sếp ra lệnh: *"Tháng này chạy chiến dịch Mùa Hè"*. Thằng CEO Agent tự động phân tích và viết vào file `TASKS.md`:
`- [ ] @marketing Viết bài content cho chiến dịch`
- **Daily Heartbeat (Cronjob):** Cứ 8h sáng, con Agent Marketing được đánh thức bằng lệnh `/update`. Nó nhìn vào file `TASKS.md`, thấy có task assign cho `@marketing`. Thế là nó đi làm.
- Làm xong, nó tick `[x]` vào task của nó, và tự động viết thêm 1 dòng mới:
`- [ ] @legal Check hộ bài content này xem có vi phạm luật không`.
- Đến lượt con Legal Agent thức dậy, nó lại thấy task của nó và tự đi làm.
👉 **Kết luận nghiệp vụ:** Thay vì mình code một cái Hook Engine bằng JSON rườm rà, mình chỉ cần xây dựng một **Hệ thống Task Management dùng chung (bảng Tasks)**. Các Agent giao tiếp với nhau bằng cách *Assign Task và @Tag tên nhau* giống hệt như con người dùng Trello/Jira!
---
## 2. Các phòng ban (Sub-agents) dùng nền tảng riêng hay tích hợp bên thứ 3?
Đọc phần `Connectors` của sếp gửi, đáp án là: **CHỈ TÍCH HỢP BÊN THỨ 3 (Dùng MCP).**
Canifa AI KHÔNG CẦN đi code lại một cái CRM cho thằng Sales, hay một cái phần mềm kế toán riêng. Điều đó quá tốn nguồn lực mà khách hàng lại không thích vì họ phải bỏ phần mềm cũ.
**Mô hình vận hành của Canifa AI Company sẽ là:**
- Nền tảng của mình cung cấp **Não bộ (Memory + Task Tracking + AI Engine)**.
- Các phòng ban sẽ "mọc tay mọc chân" bằng cách kết nối với Tool bên ngoài qua chuẩn **MCP (Model Context Protocol)**.
**Ví dụ thiết kế phòng ban cho SME:**
* **Phòng Sales (Agent Sales):** Cắm dây (MCP Connector) vào HubSpot/Salesforce đang có sẵn của công ty + Cắm dây Gmail (Microsoft 365). Thằng Agent tự đọc email khách, tự lên HubSpot update trạng thái.
* **Phòng Marketing (Agent Marketing):** Cắm dây vào Facebook Pages MCP + Notion MCP. Nó tự viết content trên Notion, sếp duyệt xong nó tự đẩy post lên Fanpage.
* **Phòng Hành chính (HR Agent):** Cắm dây vào Slack MCP + Google Calendar MCP. Ai xin nghỉ phép nó tự động check lịch sếp và báo lại qua Slack.
👉 **Kết luận nghiệp vụ:** Nền tảng của mình là một cái **Trạm trung chuyển (Hub)**. Sếp chỉ việc cài các "Plugin" là các đầu nối MCP. Con Agent nào được cấp quyền dùng Plugin nào, thì nó có sức mạnh của tool đó. Mình đứng trên vai những thằng khổng lồ (HubSpot, Slack, Microsoft) chứ không đi phát minh lại cái bánh xe!
/* shadcn/ui dark theme CSS variables — mirrors the React app */
:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 5.5%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 5.5%;
--popover-foreground: 0 0% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 18%;
--input: 240 3.7% 15.9%;
--ring: 217.2 91.2% 59.8%;
--radius: 0.5rem;
--sidebar-width: 220px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
height: 100vh;
display: flex;
overflow: hidden;
}
/* ===== SIDEBAR ===== */
#sidebar {
width: var(--sidebar-width);
background: hsl(240 10% 4.5%);
border-right: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid hsl(var(--border));
font-weight: 600;
font-size: 13px;
color: hsl(var(--foreground));
text-decoration: none;
}
.sidebar-logo-icon {
width: 22px;
height: 22px;
background: hsl(var(--primary));
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.company-switcher {
padding: 10px 10px;
border-bottom: 1px solid hsl(var(--border));
}
.company-select {
width: 100%;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
outline: none;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
padding-right: 30px;
}
.company-select:focus {
border-color: hsl(var(--primary) / 0.6);
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
}
.nav-section {
padding: 8px 0;
}
.nav-section-label {
padding: 4px 16px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: hsl(var(--muted-foreground));
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
margin: 1px 6px;
border-radius: 6px;
color: hsl(var(--muted-foreground));
text-decoration: none;
font-size: 13px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
border: none;
background: transparent;
width: calc(100% - 12px);
text-align: left;
}
.nav-item:hover { background: hsl(var(--accent)); color: hsl(var(--foreground)); }
.nav-item.active { background: hsl(var(--accent)); color: hsl(var(--foreground)); font-weight: 500; }
.nav-item svg { width: 15px; height: 15px; flex-shrink: 0; }
/* ===== MAIN ===== */
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
#topbar {
height: 46px;
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
padding: 0 20px;
gap: 6px;
flex-shrink: 0;
background: hsl(240 10% 4.5%);
}
.breadcrumb-item { font-size: 12px; color: hsl(var(--muted-foreground)); }
.breadcrumb-sep { font-size: 12px; color: hsl(var(--border)); }
.breadcrumb-item.active { color: hsl(var(--foreground)); font-weight: 500; }
#content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ===== COMPONENTS ===== */
.card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.card-hover:hover { border-color: hsl(var(--muted-foreground) / 0.3); }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.1s, border-color 0.1s;
white-space: nowrap;
text-decoration: none;
}
.btn-primary { background: hsl(var(--primary)); color: #fff; border-color: hsl(var(--primary)); }
.btn-primary:hover { background: hsl(217.2 91.2% 54%); }
.btn-outline { background: transparent; color: hsl(var(--foreground)); border-color: hsl(var(--border)); }
.btn-outline:hover { background: hsl(var(--accent)); }
.btn-ghost { background: transparent; color: hsl(var(--muted-foreground)); border-color: transparent; }
.btn-ghost:hover { background: hsl(var(--accent)); color: hsl(var(--foreground)); }
.btn-destructive { background: hsl(var(--destructive)); color: hsl(var(--destructive-foreground)); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-icon { padding: 5px; width: 28px; height: 28px; justify-content: center; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
border: 1px solid transparent;
}
.badge-active, .badge-success { background: hsl(142 76% 36% / 0.15); color: hsl(142 76% 56%); border-color: hsl(142 76% 36% / 0.3); }
.badge-paused, .badge-warning { background: hsl(38 92% 50% / 0.15); color: hsl(38 92% 60%); border-color: hsl(38 92% 50% / 0.3); }
.badge-error, .badge-failed { background: hsl(0 72% 51% / 0.15); color: hsl(0 72% 65%); border-color: hsl(0 72% 51% / 0.3); }
.badge-running { background: hsl(206 100% 50% / 0.15); color: hsl(206 100% 65%); border-color: hsl(206 100% 50% / 0.3); }
.badge-idle, .badge-default { background: hsl(var(--muted) / 0.5); color: hsl(var(--muted-foreground)); border-color: hsl(var(--border)); }
.badge-blue { background: hsl(217.2 91.2% 59.8% / 0.15); color: hsl(217.2 91.2% 70%); border-color: hsl(217.2 91.2% 59.8% / 0.3); }
.badge-done { background: hsl(var(--muted)/0.4); color: hsl(var(--muted-foreground)); border-color: hsl(var(--border)); }
.badge-in_progress { background: hsl(206 100% 50% / 0.15); color: hsl(206 100% 65%); border-color: hsl(206 100% 50% / 0.3); }
.badge-todo { background: hsl(var(--muted)/0.3); color: hsl(var(--muted-foreground)); }
.badge-cancelled { background: hsl(0 0% 50% / 0.1); color: hsl(0 0% 60%); }
.input {
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
padding: 6px 10px;
font-size: 13px;
outline: none;
width: 100%;
}
.input:focus { border-color: hsl(var(--primary) / 0.6); box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15); }
.input::placeholder { color: hsl(var(--muted-foreground)); }
.divider { border: none; border-top: 1px solid hsl(var(--border)); }
/* metric cards */
.metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.metric-card { padding: 16px; }
.metric-label { font-size: 11px; color: hsl(var(--muted-foreground)); font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
.metric-value { font-size: 22px; font-weight: 700; }
/* skeleton */
@keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} }
.skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--accent)) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
/* table */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: hsl(var(--muted-foreground)); border-bottom: 1px solid hsl(var(--border)); white-space: nowrap; }
td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid hsl(var(--border) / 0.5); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: hsl(var(--accent) / 0.3); }
/* status dot */
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.dot-active { background: hsl(142 76% 46%); box-shadow: 0 0 0 2px hsl(142 76% 46% / 0.2); }
.dot-running { background: hsl(206 100% 60%); box-shadow: 0 0 0 2px hsl(206 100% 60% / 0.2); }
.dot-error { background: hsl(0 72% 56%); }
.dot-paused { background: hsl(38 92% 55%); }
.dot-idle, .dot-default { background: hsl(var(--muted-foreground)); }
.dot-terminated { background: hsl(var(--muted)); }
/* pulse anim for running */
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
.pulse { animation: pulse 1.5s ease-in-out infinite; }
/* empty state */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px; text-align: center; gap: 10px; }
.empty-state svg { opacity: 0.2; }
.empty-state h3 { font-size: 14px; font-weight: 600; }
.empty-state p { font-size: 12px; color: hsl(var(--muted-foreground)); max-width: 300px; }
/* toast */
#toast-container { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
.toast { padding: 10px 14px; border-radius: 8px; font-size: 12px; font-weight: 500; background: hsl(var(--card)); border: 1px solid hsl(var(--border)); box-shadow: 0 8px 24px rgba(0,0,0,0.4); animation: slideIn 0.2s ease; max-width: 320px; }
.toast-success { border-color: hsl(142 76% 36% / 0.4); color: hsl(142 76% 56%); }
.toast-error { border-color: hsl(0 72% 51% / 0.4); color: hsl(0 72% 65%); }
@keyframes slideIn { from{transform:translateX(20px);opacity:0} to{transform:translateX(0);opacity:1} }
/* pipeline run selected border */
.run-item { cursor: pointer; padding: 12px 14px; border-bottom: 1px solid hsl(var(--border)/0.5); border-left: 3px solid transparent; transition: background 0.1s; }
.run-item:hover { background: hsl(var(--accent)/0.3); }
.run-item.selected { background: hsl(var(--accent)/0.4); border-left-color: hsl(var(--primary)); }
/* modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 20px; }
.modal { background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 12px; width: 100%; max-width: 500px; box-shadow: 0 24px 64px rgba(0,0,0,0.5); }
.modal-header { padding: 18px 20px; border-bottom: 1px solid hsl(var(--border)); display: flex; align-items: center; justify-content: between; }
.modal-body { padding: 20px; }
.modal-footer { padding: 14px 20px; border-top: 1px solid hsl(var(--border)); display: flex; justify-content: flex-end; gap: 8px; }
/* form */
.form-group { margin-bottom: 14px; }
.form-label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 5px; color: hsl(var(--foreground)); }
.form-hint { font-size: 11px; color: hsl(var(--muted-foreground)); margin-top: 4px; }
/* scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
/* spinner */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { width: 16px; height: 16px; border: 2px solid hsl(var(--border)); border-top-color: hsl(var(--primary)); border-radius: 50%; animation: spin 0.7s linear infinite; }
/* layout utils */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.gap-6 { gap: 24px; }
.flex-1 { flex: 1; }
.min-w-0 { min-width: 0; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.text-xs { font-size: 11px; }
.text-sm { font-size: 13px; }
.text-muted { color: hsl(var(--muted-foreground)); }
.font-mono { font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.mb-4 { margin-bottom: 16px; }
.p-4 { padding: 16px; }
.p-5 { padding: 20px; }
.p-6 { padding: 24px; }
.space-y-4 > * + * { margin-top: 16px; }
.space-y-6 > * + * { margin-top: 24px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.w-full { width: 100%; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-destructive { color: hsl(0 72% 65%); }
.text-primary { color: hsl(var(--primary)); }
.text-emerald { color: hsl(142 76% 56%); }
.text-rose { color: hsl(0 72% 65%); }
.text-sky { color: hsl(206 100% 65%); }
.text-amber { color: hsl(38 92% 60%); }
.overflow-y-auto { overflow-y: auto; }
.h-full { height: 100%; }
.border-b { border-bottom: 1px solid hsl(var(--border)); }
.border-t { border-top: 1px solid hsl(var(--border)); }
.rounded { border-radius: 6px; }
.rounded-full { border-radius: 999px; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
...@@ -400,3 +400,161 @@ h1, h2, h3 { font-weight: 600; letter-spacing: -0.02em; } ...@@ -400,3 +400,161 @@ h1, h2, h3 { font-weight: 600; letter-spacing: -0.02em; }
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
} }
/* Org Chart UI */
.org-chart-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
}
.org-level {
display: flex;
justify-content: center;
gap: 40px;
position: relative;
width: 100%;
}
.level-ceo {
margin-bottom: 20px;
}
.level-departments {
flex-wrap: wrap;
padding: 0 40px;
}
.org-node {
display: flex;
flex-direction: column;
align-items: center;
width: 280px;
}
.org-card {
width: 100%;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.3s ease;
position: relative;
z-index: 2;
}
.ceo-card {
width: 320px;
border-color: var(--primary);
box-shadow: 0 0 20px var(--primary-glow);
}
.org-card-header {
display: flex;
align-items: center;
gap: 12px;
}
.org-avatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--primary);
}
.org-info {
flex-grow: 1;
}
.org-info h3 {
font-size: 16px;
margin-bottom: 4px;
}
.org-card-body p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.4;
margin-bottom: 12px;
}
.mcp-connectors {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.mcp-badge {
font-size: 11px;
padding: 4px 8px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--panel-border);
border-radius: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.org-actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.btn-sm {
padding: 8px 16px;
font-size: 12px;
width: 100%;
justify-content: center;
}
/* Connectors */
.org-connector-vertical {
width: 2px;
height: 40px;
background: var(--panel-border);
}
.org-connector-horizontal {
width: calc(100% - 280px); /* rough estimate to connect children */
height: 2px;
background: var(--panel-border);
margin-bottom: -1px; /* overlap with vertical short */
}
.org-connector-vertical-short {
width: 2px;
height: 20px;
background: var(--panel-border);
}
/* Hire Card */
.hire-card {
border-style: dashed;
cursor: pointer;
align-items: center;
justify-content: center;
text-align: center;
min-height: 150px;
}
.hire-card:hover {
background: rgba(255,255,255,0.05);
border-color: var(--primary);
}
.hire-icon {
font-size: 32px;
color: var(--text-muted);
margin-bottom: 8px;
}
.hire-card:hover .hire-icon {
color: var(--primary);
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="vi">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canifa AI - Platform</title> <title>Paperclip OS — UI Prototype</title>
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/app.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <!-- Optional: override API base for dev (default = same-origin /api) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <!-- <script>window.API_BASE = "http://localhost:8000/api";</script> -->
</head> </head>
<body> <body>
<!-- Login Modal (Covers screen initially) -->
<div class="modal-overlay active" id="loginModal" style="z-index: 1000; background: var(--bg-dark);">
<div class="modal-content glass-panel" style="max-width: 400px;">
<div class="modal-header" style="justify-content: center; border-bottom: none;">
<h2 class="text-gradient">The Moon AI</h2>
</div>
<div class="modal-body">
<form id="loginForm" style="display: flex; flex-direction: column; gap: 16px;">
<div>
<label style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; display: block;">Email</label>
<input type="email" id="emailInput" required value="admin@canifa.com" style="width: 100%; padding: 12px; background: rgba(0,0,0,0.3); border: 1px solid var(--panel-border); color: white; border-radius: 8px; outline: none;">
</div>
<div>
<label style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; display: block;">Password</label>
<input type="password" id="passwordInput" required value="admin123" style="width: 100%; padding: 12px; background: rgba(0,0,0,0.3); border: 1px solid var(--panel-border); color: white; border-radius: 8px; outline: none;">
</div>
<button type="submit" class="btn btn-primary" id="loginBtn" style="justify-content: center; margin-top: 8px;">Sign In</button>
<p id="loginError" style="color: var(--status-error); font-size: 12px; text-align: center; display: none;"></p>
</form>
</div>
</div>
</div>
<div class="app-container" id="appContainer" style="display: none;">
<!-- Sidebar Navigation -->
<aside class="sidebar glass-panel">
<div class="brand">
<i class="fa-solid fa-moon brand-icon"></i>
<span>The Moon AI</span>
</div>
<nav class="nav-menu"> <!-- ===== SIDEBAR ===== -->
<a href="#" class="nav-item active" data-tab="dashboard"> <nav id="sidebar">
<i class="fa-solid fa-house"></i> <a class="sidebar-logo" href="#dashboard">
<span>Dashboard</span> <div class="sidebar-logo-icon">P</div>
Paperclip OS
<span style="margin-left:auto;font-size:9px;font-weight:400;color:hsl(var(--muted-foreground));background:hsl(var(--muted));padding:2px 6px;border-radius:4px">proto</span>
</a> </a>
<a href="api-sandbox.html" class="nav-item">
<i class="fa-solid fa-bolt"></i>
<span>API Sandbox</span>
</a>
<a href="#" class="nav-item" data-tab="companies">
<i class="fa-solid fa-building"></i>
<span>Companies</span>
</a>
<a href="#" class="nav-item" data-tab="agents">
<i class="fa-solid fa-robot"></i>
<span>AI Agents</span>
</a>
<a href="#" class="nav-item" data-tab="billing">
<i class="fa-solid fa-credit-card"></i>
<span>Billing</span>
</a>
</nav>
<div class="user-profile"> <!-- Company switcher -->
<div class="avatar">AD</div> <div class="company-switcher">
<div class="user-info"> <select class="company-select" id="company-select" title="Chọn phòng ban">
<span class="user-name">Admin</span> <option value="">-- Đang tải... --</option>
<span class="user-role">Superuser</span> </select>
</div> </div>
</div>
</aside>
<!-- Main Content Area --> <!-- Main nav -->
<main class="main-content"> <div class="nav-section">
<!-- Header: Company Overview --> <div class="nav-section-label">Tổng quan</div>
<header class="top-header"> <button class="nav-item" data-route="dashboard" onclick="location.hash='dashboard'">
<div class="header-title"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
<h1 id="companyNameDisplay">Loading...</h1> Dashboard
<span class="badge status-active">Active</span> </button>
</div> <button class="nav-item" data-route="companies" onclick="location.hash='companies'">
<div class="header-actions"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<div class="stat-box glass-panel"> Phòng ban
<span class="stat-label">Total Agents</span> </button>
<span class="stat-value text-gradient" id="totalAgentsDisplay">0</span>
</div>
<button class="btn btn-primary"><i class="fa-solid fa-plus"></i> New Agent</button>
<button class="btn btn-secondary" id="logoutBtn" style="margin-left: 8px;"><i class="fa-solid fa-right-from-bracket"></i></button>
</div> </div>
</header>
<!-- Agent Grid Section --> <div class="nav-section">
<section class="content-section"> <div class="nav-section-label">Vận hành</div>
<div class="section-header"> <button class="nav-item" data-route="agents" onclick="location.hash='agents'">
<h2>Active Agents</h2> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>
<div class="filters"> Agents
<button class="filter-btn active">All</button> </button>
<button class="filter-btn">Running</button> <button class="nav-item" data-route="issues" onclick="location.hash='issues'">
<button class="filter-btn">Paused</button> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div> Issues
</button>
<button class="nav-item" data-route="pipeline" onclick="location.hash='pipeline'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Pipeline Runs
</button>
<button class="nav-item" data-route="routines" onclick="location.hash='routines'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Routines
</button>
</div> </div>
<div class="agent-grid" id="agentGrid"> <!-- Bottom: prototype banner -->
<!-- Agent Cards will be injected here by JS --> <div style="margin-top:auto;padding:12px 14px;border-top:1px solid hsl(var(--border))">
</div> <p style="font-size:10px;color:hsl(var(--muted-foreground));line-height:1.5">
</section> 🧪 <strong>UI Prototype</strong> — HTML/CSS/JS thuần<br/>
</main> Gọi backend /api cùng origin.<br/>
</div> <a href="http://localhost:8000/docs" target="_blank" style="color:hsl(var(--primary))">API Docs →</a>
</p>
</div>
</nav>
<!-- Agent Detail Modal --> <!-- ===== MAIN ===== -->
<div class="modal-overlay" id="agentModal"> <div id="main">
<div class="modal-content glass-panel"> <!-- Topbar / breadcrumbs -->
<div class="modal-header"> <header id="topbar">
<h3 id="modalTitle">Agent Details</h3> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<button class="close-btn" id="closeModal"><i class="fa-solid fa-xmark"></i></button> <span class="breadcrumb-sep">/</span>
</div> <span id="breadcrumbs"><span class="breadcrumb-item active">Dashboard</span></span>
<div class="modal-body"> <div style="margin-left:auto;display:flex;align-items:center;gap:8px">
<p id="modalDesc">Detailed info will go here...</p> <span style="font-size:10px;color:hsl(var(--muted-foreground))">localhost:8000</span>
<div class="modal-stats"> <button class="btn btn-ghost btn-sm" id="btn-refresh" title="Refresh trang hiện tại">↻</button>
<div class="mini-stat">
<span>CPU</span>
<strong>45%</strong>
</div>
<div class="mini-stat">
<span>Memory</span>
<strong>1.2 GB</strong>
</div>
<div class="mini-stat">
<span>Tasks</span>
<strong>124</strong>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modalActionPause">Pause</button>
<button class="btn btn-primary">View Logs</button>
</div>
</div>
</div> </div>
</header>
<!-- Page content -->
<main id="content"></main>
</div>
<!-- Toast container (auto-created by utils.js) -->
<div id="toast-container"></div>
<script src="js/api.js"></script> <script type="module" src="js/app.js"></script>
<script src="js/ui.js"></script> <script>
<script src="js/main.js"></script> // Refresh button
document.getElementById("btn-refresh").onclick = () => {
window.dispatchEvent(new HashChangeEvent("hashchange"));
};
</script>
</body> </body>
</html> </html>
const API_BASE = 'http://localhost:3100/api'; /**
* API client — mirrors frontend/src/api/client.ts
window.ApiClient = { * Base: /api (same-origin via backend at :8000)
getToken: () => localStorage.getItem('canifa_token'), */
setToken: (token) => localStorage.setItem('canifa_token', token),
clearToken: () => localStorage.removeItem('canifa_token'), export const API_BASE = window.API_BASE ?? "/api";
fetchWithAuth: async (endpoint, options = {}) => { export class ApiError extends Error {
const token = window.ApiClient.getToken(); constructor(message, status, body) {
super(message);
const headers = { this.name = "ApiError";
'Content-Type': 'application/json', this.status = status;
...(token ? { 'Authorization': `Bearer ${token}` } : {}), this.body = body;
...(options.headers || {}) }
}; }
const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers }); async function request(path, init = {}) {
if (response.status === 401) { const headers = new Headers(init.headers ?? {});
if (window.handleLogout) window.handleLogout(); if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
throw new Error("Unauthorized"); headers.set("Content-Type", "application/json");
}
const res = await fetch(`${API_BASE}${path}`, { headers, credentials: "include", ...init });
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
throw new ApiError(errorBody?.error ?? `Request failed: ${res.status}`, res.status, errorBody);
} }
return response.json().catch(() => null); if (res.status === 204) return undefined;
return res.json();
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
patch: (path, body) => request(path, { method: "PATCH", body: JSON.stringify(body) }),
put: (path, body) => request(path, { method: "PUT", body: JSON.stringify(body) }),
delete: (path) => request(path, { method: "DELETE" }),
};
export const companiesApi = {
list: () => api.get("/companies"),
stats: () => api.get("/companies/stats"),
create: (data) => api.post("/companies", data),
update: (id, data) => api.patch(`/companies/${id}`, data),
remove: (id) => api.delete(`/companies/${id}`),
};
export const agentsApi = {
list: (cid) => api.get(`/companies/${cid}/agents`),
org: (cid) => api.get(`/companies/${cid}/org`),
get: (id) => api.get(`/agents/${id}`),
create: (cid, data) => api.post(`/companies/${cid}/agents`, data),
update: (id, data) => api.patch(`/agents/${id}`, data),
pause: (id) => api.post(`/agents/${id}/pause`, {}),
resume: (id) => api.post(`/agents/${id}/resume`, {}),
terminate: (id) => api.post(`/agents/${id}/terminate`, {}),
wakeup: (id, data = {}) => api.post(`/agents/${id}/wakeup`, data),
remove: (id) => api.delete(`/agents/${id}`),
};
export const issuesApi = {
list: (cid, filters = {}) => {
const p = new URLSearchParams({ limit: "100", ...filters });
return api.get(`/companies/${cid}/issues?${p}`);
}, },
get: (id) => api.get(`/issues/${id}`),
create: (cid, data) => api.post(`/companies/${cid}/issues`, data),
update: (id, data) => api.patch(`/issues/${id}`, data),
}; };
export const pipelineRunsApi = {
window.accessApi = { list: (cid) => api.get(`/companies/${cid}/pipeline-runs`),
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/access`, { method: 'GET' }), get: (cid, pid) => api.get(`/companies/${cid}/pipeline-runs/${pid}`),
get: (id) => window.ApiClient.fetchWithAuth(`/access/${id}`, { method: 'GET' }), run: (cid, data) => api.post(`/companies/${cid}/pipeline-runs`, data),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/access`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/access/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/access/${id}`, { method: 'DELETE' })
};
window.activityApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/activity`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/activity/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/activity`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/activity/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/activity/${id}`, { method: 'DELETE' })
};
window.adaptersApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/adapters`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/adapters/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/adapters`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/adapters/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/adapters/${id}`, { method: 'DELETE' })
};
window.agentsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/agents`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/agents/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/agents`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/agents/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/agents/${id}`, { method: 'DELETE' })
};
window.approvalsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/approvals`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/approvals/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/approvals`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/approvals/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/approvals/${id}`, { method: 'DELETE' })
};
window.assetsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/assets`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/assets/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/assets`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/assets/${id}`, { method: 'DELETE' })
};
window.authApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/auth`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/auth/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/auth`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/auth/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/auth/${id}`, { method: 'DELETE' })
};
window.blueprintsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/blueprints`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/blueprints/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/blueprints`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/blueprints/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/blueprints/${id}`, { method: 'DELETE' })
};
window.budgetsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/budgets`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/budgets/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/budgets`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/budgets/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/budgets/${id}`, { method: 'DELETE' })
};
window.companiesApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/companies`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/companies/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/companies`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/companies/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/companies/${id}`, { method: 'DELETE' })
};
window.companySkillsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/companySkills`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/companySkills/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/companySkills`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/companySkills/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/companySkills/${id}`, { method: 'DELETE' })
};
window.costsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/costs`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/costs/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/costs`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/costs/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/costs/${id}`, { method: 'DELETE' })
};
window.cubesandboxApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/cubesandbox`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/cubesandbox/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/cubesandbox`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/cubesandbox/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/cubesandbox/${id}`, { method: 'DELETE' })
};
window.dashboardApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/dashboard`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/dashboard/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/dashboard`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/dashboard/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/dashboard/${id}`, { method: 'DELETE' })
};
window.environmentsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/environments`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/environments/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/environments`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/environments/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/environments/${id}`, { method: 'DELETE' })
};
window.executionWorkspacesApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/execution-workspaces`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/execution-workspaces/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/execution-workspaces`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/execution-workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/execution-workspaces/${id}`, { method: 'DELETE' })
};
window.goalsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/goals`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/goals/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/goals`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/goals/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/goals/${id}`, { method: 'DELETE' })
};
window.healthApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/health`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/health/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/health`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/health/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/health/${id}`, { method: 'DELETE' })
};
window.heartbeatsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/heartbeats`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/heartbeats/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/heartbeats`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/heartbeats/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/heartbeats/${id}`, { method: 'DELETE' })
};
window.inboxDismissalsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/inboxDismissals`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/inboxDismissals/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/inboxDismissals`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/inboxDismissals/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/inboxDismissals/${id}`, { method: 'DELETE' })
};
window.instanceSettingsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/instanceSettings`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/instanceSettings/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/instanceSettings`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/instanceSettings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/instanceSettings/${id}`, { method: 'DELETE' })
}; };
export const routinesApi = {
window.issuesApi = { list: (cid) => api.get(`/companies/${cid}/routines`),
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/issues`, { method: 'GET' }), get: (id) => api.get(`/routines/${id}`),
get: (id) => window.ApiClient.fetchWithAuth(`/issues/${id}`, { method: 'GET' }), create: (cid, data) => api.post(`/companies/${cid}/routines`, data),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/issues`, { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => api.patch(`/routines/${id}`, data),
update: (id, data) => window.ApiClient.fetchWithAuth(`/issues/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), run: (id, data = {}) => api.post(`/routines/${id}/run`, data),
delete: (id) => window.ApiClient.fetchWithAuth(`/issues/${id}`, { method: 'DELETE' })
}; };
export const dashboardApi = {
window.marketplaceApi = { summary: (cid) => api.get(`/companies/${cid}/dashboard`),
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/marketplace`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/marketplace/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/marketplace`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/marketplace/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/marketplace/${id}`, { method: 'DELETE' })
}; };
export const blueprintsApi = {
window.pipelineRunsApi = { list: () => api.get("/blueprints"),
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/pipelineRuns`, { method: 'GET' }), deploy: (data) => api.post("/blueprints/deploy", data),
get: (id) => window.ApiClient.fetchWithAuth(`/pipelineRuns/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/pipelineRuns`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/pipelineRuns/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/pipelineRuns/${id}`, { method: 'DELETE' })
}; };
window.pluginsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/plugins`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/plugins/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/plugins`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/plugins/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/plugins/${id}`, { method: 'DELETE' })
};
window.projectsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/projects`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/projects/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/projects`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/projects/${id}`, { method: 'DELETE' })
};
window.routinesApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/routines`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/routines/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/routines`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/routines/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/routines/${id}`, { method: 'DELETE' })
};
window.searchApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/search`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/search/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/search`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/search/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/search/${id}`, { method: 'DELETE' })
};
window.secretsApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/secrets`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/secrets/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/secrets`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/secrets/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/secrets/${id}`, { method: 'DELETE' })
};
window.sidebarBadgesApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/sidebarBadges`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/sidebarBadges/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/sidebarBadges`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/sidebarBadges/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/sidebarBadges/${id}`, { method: 'DELETE' })
};
window.sidebarPreferencesApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/sidebarPreferences`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/sidebarPreferences/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/sidebarPreferences`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/sidebarPreferences/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/sidebarPreferences/${id}`, { method: 'DELETE' })
};
window.userProfilesApi = {
list: (companyId) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/userProfiles`, { method: 'GET' }),
get: (id) => window.ApiClient.fetchWithAuth(`/userProfiles/${id}`, { method: 'GET' }),
create: (companyId, data) => window.ApiClient.fetchWithAuth(`/companies/${companyId}/userProfiles`, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => window.ApiClient.fetchWithAuth(`/userProfiles/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id) => window.ApiClient.fetchWithAuth(`/userProfiles/${id}`, { method: 'DELETE' })
};
// Main app entry — shared state + sidebar wiring + route init
import { registerRoute, initRouter, navigate } from "./router.js";
import { companiesPage } from "./pages/companies.js";
import { dashboardPage } from "./pages/dashboard.js";
import { agentsPage } from "./pages/agents.js";
import { issuesPage } from "./pages/issues.js";
import { pipelinePage } from "./pages/pipeline.js";
import { routinesPage } from "./pages/routines.js";
import { companiesApi } from "./api.js";
// ===== Shared state =====
let _selectedCompanyId = localStorage.getItem("selectedCompanyId") ?? null;
let _companies = [];
export function getSelectedCompanyId() { return _selectedCompanyId; }
export function setSelectedCompanyId(id) {
_selectedCompanyId = id;
localStorage.setItem("selectedCompanyId", id);
updateCompanySelect(id);
}
export function setCompanySelectOptions(companies) {
_companies = companies;
const sel = document.getElementById("company-select");
if (!sel) return;
sel.innerHTML = companies.length === 0
? `<option value="">-- Chưa có phòng ban --</option>`
: companies.map(c => `<option value="${c.id}" ${c.id === _selectedCompanyId ? "selected" : ""}>${c.name}</option>`).join("");
}
function updateCompanySelect(id) {
const sel = document.getElementById("company-select");
if (sel) sel.value = id ?? "";
}
// ===== Route registration =====
registerRoute("companies", companiesPage);
registerRoute("dashboard", dashboardPage);
registerRoute("agents", agentsPage);
registerRoute("issues", issuesPage);
registerRoute("pipeline", pipelinePage);
registerRoute("routines", routinesPage);
registerRoute("404", (_p, container) => {
container.innerHTML = `<div class="empty-state"><h3>404 — Không tìm thấy trang</h3><a href="#dashboard" style="color:hsl(var(--primary))">← Về Dashboard</a></div>`;
});
// ===== Breadcrumb update on navigate =====
const PAGE_LABELS = {
companies: ["Phòng ban"],
dashboard: ["Bảng điều khiển"],
agents: ["Agents"],
issues: ["Issues"],
pipeline: ["Pipeline Runs"],
routines: ["Routines"],
};
window.addEventListener("hashchange", () => {
const route = window.location.hash.replace("#", "").split("/")[0] || "dashboard";
const labels = PAGE_LABELS[route] ?? [route];
const bc = document.getElementById("breadcrumbs");
if (bc) {
bc.innerHTML = labels.map((l, i) =>
i === labels.length - 1
? `<span class="breadcrumb-item active">${l}</span>`
: `<span class="breadcrumb-item">${l}</span><span class="breadcrumb-sep"> / </span>`
).join("");
}
});
// ===== Company switcher =====
document.getElementById("company-select")?.addEventListener("change", (e) => {
setSelectedCompanyId(e.target.value);
const route = window.location.hash.replace("#", "").split("/")[0] || "dashboard";
// Re-render current page
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
// ===== Load companies for switcher on boot =====
companiesApi.list().then(companies => {
setCompanySelectOptions(companies);
_companies = companies;
// Auto-select first if saved ID gone
if (_selectedCompanyId && !companies.find(c => c.id === _selectedCompanyId)) {
if (companies.length) setSelectedCompanyId(companies[0].id);
else _selectedCompanyId = null;
}
if (!_selectedCompanyId && companies.length) {
setSelectedCompanyId(companies[0].id);
}
updateCompanySelect(_selectedCompanyId);
}).catch(() => {});
// ===== Start router =====
initRouter();
import { agentsApi } from "../api.js";
import { relativeTime, statusBadge, statusDot, escHtml, toast, confirmDialog } from "../utils.js";
import { getSelectedCompanyId } from "../app.js";
export async function agentsPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) { container.innerHTML = noCompany(); return; }
const agents = await agentsApi.list(cid);
container.innerHTML = `
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="font-semibold" style="font-size:16px">Agents (${agents.length})</h1>
<button class="btn btn-primary btn-sm" id="btn-new-agent">+ Thêm Agent</button>
</div>
<!-- Filter tabs -->
<div class="flex gap-1" style="border-bottom:1px solid hsl(var(--border));padding-bottom:0">
${["all","active","paused","error"].map(t => `
<button class="btn btn-ghost btn-sm tab-filter" data-tab="${t}" style="border-radius:6px 6px 0 0;padding-bottom:8px">
${t === "all" ? "Tất cả" : t === "active" ? "Hoạt động" : t === "paused" ? "Tạm dừng" : "Lỗi"}
</button>`).join("")}
</div>
<div id="agents-table" class="card"></div>
</div>`;
let currentTab = "all";
const render = () => renderTable(container.querySelector("#agents-table"), agents, currentTab, cid);
render();
container.querySelectorAll(".tab-filter").forEach(btn => {
btn.onclick = () => { currentTab = btn.dataset.tab; render(); };
});
container.querySelector("#btn-new-agent").onclick = () => showNewAgentModal(cid, agents);
}
function renderTable(el, agents, tab, cid) {
const filtered = agents.filter(a => {
if (a.status === "terminated") return false;
if (tab === "all") return true;
if (tab === "active") return ["active","running","idle"].includes(a.status);
return a.status === tab;
});
if (!filtered.length) {
el.innerHTML = `<div class="empty-state" style="padding:48px">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>
<h3>Không có agent nào</h3><p>Thêm agent mới để bắt đầu.</p>
</div>`;
return;
}
el.innerHTML = `<div class="table-wrap"><table>
<thead><tr>
<th>Tên</th><th>Vai trò</th><th>Trạng thái</th><th>Model</th><th>Cập nhật</th><th></th>
</tr></thead>
<tbody>
${filtered.map(a => `<tr>
<td>
<div class="flex items-center gap-2">
${statusDot(a.status)}
<span class="font-semibold">${escHtml(a.name)}</span>
</div>
</td>
<td class="text-muted text-xs">${escHtml(a.role ?? "—")}</td>
<td>${statusBadge(a.status)}</td>
<td class="text-xs font-mono text-muted">${escHtml(a.adapterConfig?.model ?? "—")}</td>
<td class="text-xs text-muted">${relativeTime(a.updatedAt)}</td>
<td>
<div class="flex gap-1">
${a.status === "paused"
? `<button class="btn btn-outline btn-sm" data-action="resume" data-id="${a.id}">▶ Resume</button>`
: `<button class="btn btn-outline btn-sm" data-action="pause" data-id="${a.id}">⏸ Pause</button>`}
<button class="btn btn-outline btn-sm" data-action="wakeup" data-id="${a.id}">⚡ Wake</button>
<button class="btn btn-ghost btn-sm" data-action="delete" data-id="${a.id}" title="Xoá">🗑</button>
</div>
</td>
</tr>`).join("")}
</tbody>
</table></div>`;
el.querySelectorAll("[data-action]").forEach(btn => {
btn.onclick = async () => {
const { action, id } = btn.dataset;
try {
if (action === "pause") { await agentsApi.pause(id); toast("Đã tạm dừng agent"); }
else if (action === "resume") { await agentsApi.resume(id); toast("Đã tiếp tục agent"); }
else if (action === "wakeup") { await agentsApi.wakeup(id, { source: "on_demand", triggerDetail: "manual" }); toast("Đã gửi wake-up"); }
else if (action === "delete") {
const ok = await confirmDialog("Xoá agent này?");
if (!ok) return;
await agentsApi.remove(id);
toast("Đã xoá agent");
}
// Refresh
const newAgents = await agentsApi.list(cid);
renderTable(el, newAgents, "all", cid);
} catch (err) { toast(err.message, "error"); }
};
});
}
function showNewAgentModal(cid, agents) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Thêm Agent mới</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tên Agent *</label><input class="input" id="a-name" placeholder="CEO, Copywriter..."></div>
<div class="form-group"><label class="form-label">Vai trò</label><input class="input" id="a-role" placeholder="ceo, copywriter, developer..."></div>
<div class="form-group"><label class="form-label">Hệ thống prompt</label><textarea class="input" id="a-prompt" rows="3" placeholder="Bạn là..."></textarea></div>
<div class="form-group">
<label class="form-label">Báo cáo cho (Reports to)</label>
<select class="input" id="a-reports">
<option value="">-- Không --</option>
${agents.map(a => `<option value="${a.id}">${escHtml(a.name)}</option>`).join("")}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo Agent</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const name = backdrop.querySelector("#a-name").value.trim();
if (!name) return toast("Cần nhập tên agent", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true;
try {
await agentsApi.create(cid, {
name,
role: backdrop.querySelector("#a-role").value || undefined,
promptTemplate: backdrop.querySelector("#a-prompt").value || undefined,
reportsTo: backdrop.querySelector("#a-reports").value || undefined,
});
toast("Đã tạo agent");
close();
location.hash = "agents";
} catch (err) { toast(err.message, "error"); btn.disabled = false; }
};
}
function noCompany() {
return `<div class="empty-state">
<h3>Chưa chọn phòng ban</h3>
<p><a href="#companies" style="color:hsl(var(--primary))">Chọn phòng ban</a> trước.</p>
</div>`;
}
import { companiesApi, blueprintsApi } from "../api.js";
import { formatCents, relativeTime, statusBadge, escHtml, toast, confirmDialog } from "../utils.js";
import { navigate } from "../router.js";
import { getSelectedCompanyId, setSelectedCompanyId, setCompanySelectOptions } from "../app.js";
export async function companiesPage(_params, container) {
const [companies, stats, blueprints] = await Promise.all([
companiesApi.list(),
companiesApi.stats().catch(() => ({})),
blueprintsApi.list().catch(() => []),
]);
setCompanySelectOptions(companies);
container.innerHTML = `
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="font-semibold" style="font-size:16px">Phòng ban (${companies.length})</h1>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm" id="btn-create-manual">+ Tạo thủ công</button>
<button class="btn btn-primary btn-sm" id="btn-deploy-blueprint">+ Deploy Blueprint</button>
</div>
</div>
<div id="companies-list" class="space-y-4"></div>
</div>`;
renderList(container.querySelector("#companies-list"), companies, stats);
container.querySelector("#btn-create-manual").onclick = () => showCreateModal(container, blueprints, companies);
container.querySelector("#btn-deploy-blueprint").onclick = () => showDeployModal(container, blueprints, companies);
}
function renderList(listEl, companies, stats) {
if (!companies.length) {
listEl.innerHTML = `<div class="empty-state card">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<h3>Chưa có phòng ban nào</h3>
<p>Tạo phòng ban hoặc deploy từ blueprint để bắt đầu.</p>
</div>`;
return;
}
const selectedId = getSelectedCompanyId();
listEl.innerHTML = companies.map((c) => {
const s = stats[c.id] ?? {};
const agentCount = s.agentCount ?? 0;
const issueCount = s.issueCount ?? 0;
const budgetPct = c.budgetMonthlyCents > 0
? Math.round((c.spentMonthlyCents / c.budgetMonthlyCents) * 100) : 0;
const selected = c.id === selectedId;
return `<div class="card card-hover p-5" data-id="${c.id}" style="cursor:pointer;${selected ? "border-color:hsl(var(--primary));box-shadow:0 0 0 1px hsl(var(--primary))" : ""}">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-semibold" style="font-size:15px">${escHtml(c.name)}</h3>
${statusBadge(c.status)}
${c.blueprint_name ? `<span class="badge badge-blue">${escHtml(c.blueprint_name)}</span>` : ""}
${selected ? `<span class="badge badge-active" style="font-size:9px">✓ Đang chọn</span>` : ""}
</div>
${c.description ? `<p class="text-xs text-muted mt-1 line-clamp-2">${escHtml(c.description)}</p>` : ""}
</div>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm btn-select" data-id="${c.id}">Chọn</button>
<button class="btn btn-ghost btn-sm btn-delete" data-id="${c.id}" title="Xoá">🗑</button>
</div>
</div>
<div class="flex gap-4 mt-3 text-xs text-muted flex-wrap">
<span>👥 ${agentCount} agent</span>
<span>🔵 ${issueCount} issue</span>
<span>💵 ${formatCents(c.spentMonthlyCents)}${c.budgetMonthlyCents > 0 ? ` / ${formatCents(c.budgetMonthlyCents)} (${budgetPct}%)` : " (Không giới hạn)"}</span>
<span class="ml-auto">Tạo ${relativeTime(c.createdAt)}</span>
</div>
</div>`;
}).join("");
listEl.querySelectorAll(".btn-select").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
setSelectedCompanyId(btn.dataset.id);
navigate("dashboard");
toast("Đã chọn phòng ban");
};
});
listEl.querySelectorAll("[data-id]").forEach((el) => {
if (!el.classList.contains("btn")) {
el.onclick = () => { setSelectedCompanyId(el.dataset.id); navigate("dashboard"); };
}
});
listEl.querySelectorAll(".btn-delete").forEach((btn) => {
btn.onclick = async (e) => {
e.stopPropagation();
const ok = await confirmDialog("Xoá phòng ban này? Không thể hoàn tác.");
if (!ok) return;
try {
await companiesApi.remove(btn.dataset.id);
toast("Đã xoá phòng ban");
navigate("companies");
} catch (err) { toast(err.message, "error"); }
};
});
}
function showCreateModal(container, blueprints, companies) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Tạo phòng ban</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tên *</label><input class="input" id="c-name" placeholder="AI Marketing Agency"></div>
<div class="form-group"><label class="form-label">Mô tả</label><input class="input" id="c-desc" placeholder="Mô tả ngắn..."></div>
<div class="form-group"><label class="form-label">Ngân sách/tháng (USD, 0 = không giới hạn)</label><input class="input" id="c-budget" type="number" min="0" value="0" placeholder="0"></div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const name = backdrop.querySelector("#c-name").value.trim();
if (!name) return toast("Cần nhập tên", "error");
const budget = parseInt(backdrop.querySelector("#c-budget").value) || 0;
const desc = backdrop.querySelector("#c-desc").value.trim();
try {
await companiesApi.create({ name, description: desc || null, budgetMonthlyCents: budget * 100 });
toast("Đã tạo phòng ban");
close();
navigate("companies");
} catch (err) { toast(err.message, "error"); }
};
}
function showDeployModal(container, blueprints, companies) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Deploy từ Blueprint</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Blueprint *</label>
<select class="input" id="bp-select">
<option value="">-- Chọn blueprint --</option>
${blueprints.map(b => `<option value="${escHtml(b.name)}">${escHtml(b.name)}</option>`).join("")}
</select>
</div>
<div class="form-group"><label class="form-label">Tên công ty *</label><input class="input" id="bp-name" placeholder="Tên của công ty mới"></div>
<div class="form-group"><label class="form-label">Model AI</label><input class="input" id="bp-model" value="claude-3-5-haiku-20241022"></div>
<div class="form-group"><label class="form-label">OpenAI-compat API Key</label><input class="input" id="bp-key" type="password" placeholder="sk-..."></div>
<div class="form-group"><label class="form-label">Base URL (tuỳ chọn)</label><input class="input" id="bp-url" placeholder="https://api.anthropic.com/v1"></div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Deploy</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const blueprint_name = backdrop.querySelector("#bp-select").value;
const company_name = backdrop.querySelector("#bp-name").value.trim();
if (!blueprint_name || !company_name) return toast("Cần chọn blueprint và nhập tên", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true; btn.textContent = "Đang deploy...";
try {
const result = await blueprintsApi.deploy({
blueprint_name, company_name,
model_name: backdrop.querySelector("#bp-model").value || undefined,
openai_api_key: backdrop.querySelector("#bp-key").value || undefined,
base_url: backdrop.querySelector("#bp-url").value || undefined,
});
toast(`Deploy thành công: ${result.company?.name ?? company_name}`);
if (result.company?.id) setSelectedCompanyId(result.company.id);
close();
navigate("companies");
} catch (err) { toast(err.message, "error"); btn.disabled = false; btn.textContent = "Deploy"; }
};
}
import { dashboardApi, issuesApi, agentsApi } from "../api.js";
import { formatCents, relativeTime, statusBadge, statusDot, escHtml, toast } from "../utils.js";
import { navigate } from "../router.js";
import { getSelectedCompanyId } from "../app.js";
export async function dashboardPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) {
container.innerHTML = `<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<h3>Chọn phòng ban</h3>
<p>Vào mục <a href="#companies" style="color:hsl(var(--primary))">Phòng ban</a> để chọn công ty.</p>
</div>`;
return;
}
const [summary, agents, issues] = await Promise.all([
dashboardApi.summary(cid).catch(() => ({})),
agentsApi.list(cid).catch(() => []),
issuesApi.list(cid, { limit: "10" }).catch(() => []),
]);
const activeAgents = agents.filter(a => a.status === "active" || a.status === "running" || a.status === "idle");
const openIssues = issues.filter(i => i.status !== "done" && i.status !== "cancelled");
container.innerHTML = `
<div class="space-y-6">
<!-- Metrics -->
<div class="metric-grid">
${metricCard("🤖", "Agents", activeAgents.length, `${agents.length} tổng`, "agents")}
${metricCard("🔵", "Issues đang mở", openIssues.length, `${issues.length} tổng`, "issues")}
${metricCard("💵", "Chi phí tháng này", formatCents(summary.totalSpentCents ?? 0), summary.budgetMonthlyCents > 0 ? `/ ${formatCents(summary.budgetMonthlyCents)}` : "Không giới hạn", null)}
${metricCard("📋", "Pipeline chạy", summary.pipelineRunCount ?? "—", "tháng này", "pipeline")}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<!-- Active agents -->
<div class="card">
<div class="border-b p-4 flex items-center justify-between">
<h2 style="font-size:13px;font-weight:600">Agents đang hoạt động</h2>
<button class="btn btn-ghost btn-sm" onclick="location.hash='agents'">Xem tất cả →</button>
</div>
<div id="active-agents-list"></div>
</div>
<!-- Recent issues -->
<div class="card">
<div class="border-b p-4 flex items-center justify-between">
<h2 style="font-size:13px;font-weight:600">Issues gần đây</h2>
<button class="btn btn-ghost btn-sm" onclick="location.hash='issues'">Xem tất cả →</button>
</div>
<div id="recent-issues-list"></div>
</div>
</div>
</div>`;
// Active agents
const agentsList = container.querySelector("#active-agents-list");
if (!activeAgents.length) {
agentsList.innerHTML = `<div class="empty-state" style="padding:32px"><p>Chưa có agent nào hoạt động</p></div>`;
} else {
agentsList.innerHTML = activeAgents.slice(0, 6).map(a => `
<div class="flex items-center gap-3 p-3 border-b" style="border-color:hsl(var(--border)/0.5)">
${statusDot(a.status)}
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">${escHtml(a.name)}</p>
<p class="text-xs text-muted">${escHtml(a.role ?? "—")}</p>
</div>
${statusBadge(a.status)}
</div>`).join("");
}
// Recent issues
const issuesList = container.querySelector("#recent-issues-list");
if (!issues.length) {
issuesList.innerHTML = `<div class="empty-state" style="padding:32px"><p>Chưa có issue nào</p></div>`;
} else {
issuesList.innerHTML = issues.slice(0, 8).map(i => `
<div class="flex items-center gap-3 p-3 border-b" style="border-color:hsl(var(--border)/0.5);cursor:pointer" onclick="location.hash='issues'">
<div class="flex-1 min-w-0">
<p class="text-sm truncate">${escHtml(i.title)}</p>
<p class="text-xs text-muted">${relativeTime(i.updatedAt)}</p>
</div>
${statusBadge(i.status)}
</div>`).join("");
}
}
function metricCard(icon, label, value, sub, route) {
const click = route ? `onclick="location.hash='${route}'" style="cursor:pointer"` : "";
return `<div class="card metric-card" ${click}>
<div class="metric-label">${icon} ${escHtml(label)}</div>
<div class="metric-value">${escHtml(String(value))}</div>
<div class="text-xs text-muted mt-1">${escHtml(String(sub))}</div>
</div>`;
}
import { issuesApi, agentsApi } from "../api.js";
import { relativeTime, statusBadge, escHtml, toast } from "../utils.js";
import { getSelectedCompanyId } from "../app.js";
export async function issuesPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) { container.innerHTML = noCompany(); return; }
const [issues, agents] = await Promise.all([
issuesApi.list(cid, { limit: "200" }),
agentsApi.list(cid).catch(() => []),
]);
const agentMap = Object.fromEntries(agents.map(a => [a.id, a]));
container.innerHTML = `
<div class="space-y-4">
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="font-semibold" style="font-size:16px">Issues (${issues.length})</h1>
<div class="flex gap-2 items-center">
<input class="input" id="issue-search" placeholder="🔍 Tìm kiếm..." style="width:220px">
<select class="input" id="status-filter" style="width:140px">
<option value="">Tất cả trạng thái</option>
<option value="todo">Cần làm</option>
<option value="in_progress">Đang làm</option>
<option value="done">Xong</option>
<option value="cancelled">Huỷ</option>
</select>
<button class="btn btn-primary btn-sm" id="btn-new-issue">+ Tạo Issue</button>
</div>
</div>
<div id="issues-table" class="card"></div>
</div>`;
let search = "";
let statusFilter = "";
const render = () => {
const filtered = issues.filter(i => {
const matchSearch = !search || i.title.toLowerCase().includes(search.toLowerCase());
const matchStatus = !statusFilter || i.status === statusFilter;
return matchSearch && matchStatus;
});
renderTable(container.querySelector("#issues-table"), filtered, agentMap);
};
render();
container.querySelector("#issue-search").oninput = (e) => { search = e.target.value; render(); };
container.querySelector("#status-filter").onchange = (e) => { statusFilter = e.target.value; render(); };
container.querySelector("#btn-new-issue").onclick = () => showNewIssueModal(cid, agents, issues, render);
}
function renderTable(el, issues, agentMap) {
if (!issues.length) {
el.innerHTML = `<div class="empty-state" style="padding:48px">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<h3>Không có issue nào</h3><p>Tạo issue để agent bắt đầu làm việc.</p>
</div>`;
return;
}
el.innerHTML = `<div class="table-wrap"><table>
<thead><tr>
<th style="width:80px">ID</th><th>Tiêu đề</th><th>Trạng thái</th><th>Agent</th><th>Cập nhật</th>
</tr></thead>
<tbody>
${issues.map(i => {
const agent = i.assigneeAgentId ? agentMap[i.assigneeAgentId] : null;
return `<tr style="cursor:pointer">
<td class="font-mono text-xs text-muted">${escHtml(i.identifier ?? i.id.slice(0, 8))}</td>
<td>
<p class="font-semibold text-sm truncate" style="max-width:400px">${escHtml(i.title)}</p>
${i.description ? `<p class="text-xs text-muted line-clamp-2" style="max-width:400px">${escHtml(i.description)}</p>` : ""}
</td>
<td>${statusBadge(i.status)}</td>
<td class="text-xs text-muted">${agent ? escHtml(agent.name) : "—"}</td>
<td class="text-xs text-muted">${relativeTime(i.updatedAt)}</td>
</tr>`;
}).join("")}
</tbody>
</table></div>`;
}
function showNewIssueModal(cid, agents, issues, onSuccess) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Tạo Issue mới</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tiêu đề *</label><input class="input" id="i-title" placeholder="Làm gì đó..."></div>
<div class="form-group"><label class="form-label">Mô tả</label><textarea class="input" id="i-desc" rows="3" placeholder="Chi tiết..."></textarea></div>
<div class="form-group">
<label class="form-label">Giao cho Agent</label>
<select class="input" id="i-agent">
<option value="">-- Chưa giao --</option>
${agents.map(a => `<option value="${a.id}">${escHtml(a.name)}</option>`).join("")}
</select>
</div>
<div class="form-group">
<label class="form-label">Issue cha</label>
<select class="input" id="i-parent">
<option value="">-- Không --</option>
${issues.slice(0, 50).map(i => `<option value="${i.id}">${escHtml(i.identifier ?? i.id.slice(0,8))} — ${escHtml(i.title.slice(0,40))}</option>`).join("")}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const title = backdrop.querySelector("#i-title").value.trim();
if (!title) return toast("Cần nhập tiêu đề", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true;
try {
await issuesApi.create(cid, {
title,
description: backdrop.querySelector("#i-desc").value || undefined,
assigneeAgentId: backdrop.querySelector("#i-agent").value || undefined,
parentId: backdrop.querySelector("#i-parent").value || undefined,
});
toast("Đã tạo issue");
close();
location.hash = "issues";
} catch (err) { toast(err.message, "error"); btn.disabled = false; }
};
}
function noCompany() {
return `<div class="empty-state"><h3>Chưa chọn phòng ban</h3><p><a href="#companies" style="color:hsl(var(--primary))">Chọn phòng ban</a> trước.</p></div>`;
}
import { pipelineRunsApi, agentsApi } from "../api.js";
import { relativeTime, escHtml, toast } from "../utils.js";
import { getSelectedCompanyId } from "../app.js";
export async function pipelinePage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) { container.innerHTML = noCompany(); return; }
container.innerHTML = `
<div style="display:flex;height:calc(100vh - 100px);min-height:0;gap:16px">
<!-- Left: runs list -->
<div class="card" style="width:280px;flex-shrink:0;display:flex;flex-direction:column;overflow:hidden">
<div class="border-b p-4 flex items-center justify-between">
<h2 style="font-size:13px;font-weight:600">📋 Pipeline Runs</h2>
<button class="btn btn-primary btn-sm" id="btn-run">▶ Chạy</button>
</div>
<div id="runs-list" class="overflow-y-auto flex-1">
<div class="flex items-center gap-2" style="padding:20px;color:hsl(var(--muted-foreground))">
<div class="spinner"></div> Đang tải...
</div>
</div>
</div>
<!-- Right: detail -->
<div class="card flex-1 min-w-0" style="display:flex;flex-direction:column;overflow:hidden">
<div id="run-detail" style="flex:1;overflow-y:auto;display:flex;align-items:center;justify-content:center">
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<h3>Chọn một phiên chạy</h3>
<p>Hoặc nhấn ▶ Chạy để khởi động pipeline mới.</p>
</div>
</div>
</div>
</div>`;
let selectedId = null;
let pollTimer = null;
const loadRuns = async () => {
try {
const runs = await pipelineRunsApi.list(cid);
renderRunsList(container.querySelector("#runs-list"), runs, selectedId, selectRun);
if (!selectedId && runs.length > 0) selectRun(runs[0].pipelineId, runs[0].status);
} catch (err) {
container.querySelector("#runs-list").innerHTML = `<p style="padding:16px;color:hsl(var(--muted-foreground));font-size:12px">Lỗi: ${escHtml(err.message)}</p>`;
}
};
const selectRun = async (pid, status) => {
selectedId = pid;
container.querySelectorAll(".run-item").forEach(el => el.classList.toggle("selected", el.dataset.pid === pid));
try {
const detail = await pipelineRunsApi.get(cid, pid);
renderDetail(container.querySelector("#run-detail"), detail, cid);
// Poll if running
clearInterval(pollTimer);
if (detail.status === "running") {
pollTimer = setInterval(async () => {
const updated = await pipelineRunsApi.get(cid, pid).catch(() => null);
if (updated) renderDetail(container.querySelector("#run-detail"), updated, cid);
if (updated?.status !== "running") { clearInterval(pollTimer); loadRuns(); }
}, 5000);
}
} catch (err) {
container.querySelector("#run-detail").innerHTML = `<p style="padding:24px;color:hsl(var(--muted-foreground));font-size:12px">Lỗi: ${escHtml(err.message)}</p>`;
}
};
container.querySelector("#btn-run").onclick = () => showRunModal(cid, loadRuns);
await loadRuns();
// Poll runs list every 15s
setInterval(loadRuns, 15000);
}
function renderRunsList(el, runs, selectedId, onSelect) {
if (!runs.length) {
el.innerHTML = `<div class="empty-state" style="padding:32px"><p>Chưa có phiên chạy nào</p></div>`;
return;
}
el.innerHTML = runs.map(r => {
const statusEmoji = { completed: "✅", failed: "❌", running: "⏳" }[r.status] ?? "🕐";
return `<div class="run-item ${r.pipelineId === selectedId ? "selected" : ""}" data-pid="${r.pipelineId}">
<div class="flex items-center justify-between">
<span class="font-mono text-xs">#${r.pipelineId.slice(0,8)}</span>
<span style="font-size:10px">${statusEmoji} ${r.status === "completed" ? "Xong" : r.status === "failed" ? "Lỗi" : "Chạy"}</span>
</div>
<div class="flex items-center justify-between text-xs text-muted mt-1">
<span>${new Date(r.startedAt).toLocaleDateString("vi-VN")} ${new Date(r.startedAt).toLocaleTimeString("vi-VN")}</span>
<span>${r.durationS ?? "?"}s</span>
</div>
<div class="text-xs mt-1">
<span class="text-emerald">✓ ${r.agentsSucceeded}</span>
${r.agentsFailed > 0 ? `<span class="text-rose" style="margin-left:6px">✗ ${r.agentsFailed}</span>` : ""}
</div>
</div>`;
}).join("");
el.querySelectorAll(".run-item").forEach(item => {
item.onclick = () => onSelect(item.dataset.pid);
});
}
function renderDetail(el, detail, cid) {
const statusIcon = { completed: "✅", failed: "❌", running: "⏳" }[detail.status] ?? "🕐";
el.innerHTML = `
<div style="height:100%;display:flex;flex-direction:column">
<!-- Header -->
<div class="border-b p-5" style="background:hsl(var(--muted)/0.2)">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="font-mono font-semibold" style="font-size:15px">Run #${escHtml(detail.pipelineId)}</h2>
<span>${statusIcon} ${detail.status === "completed" ? "Hoàn thành" : detail.status === "failed" ? "Thất bại" : "Đang chạy"}</span>
</div>
<p class="text-xs text-muted mt-1">
Bắt đầu: ${new Date(detail.startedAt).toLocaleString("vi-VN")} • ${detail.durationS}s
• ${detail.agentsExecuted} agents • ${detail.agentsSucceeded} thành công
</p>
</div>
<!-- Steps -->
<div class="overflow-y-auto p-5 space-y-4">
<h3 class="font-semibold text-sm">Trình tự thực thi (${detail.steps?.length ?? 0} bước)</h3>
<div class="card" style="overflow:hidden">
${detail.steps?.map(step => {
const sIcon = { completed: "✅", failed: "❌", running: "⏳" }[step.status] ?? "🔲";
return `<div style="padding:14px;border-bottom:1px solid hsl(var(--border)/0.4);display:flex;gap:12px;align-items:flex-start">
<span style="font-size:16px;flex-shrink:0;margin-top:1px">${sIcon}</span>
<div style="flex:1;min-width:0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">${escHtml(step.agentName)}</span>
<span class="badge badge-default font-mono">${escHtml(step.role)}</span>
</div>
${step.error ? `<div style="margin-top:6px;padding:8px;border:1px solid hsl(0 72% 51%/0.2);background:hsl(0 72% 51%/0.05);border-radius:4px;font-size:11px;font-family:monospace;color:hsl(0 72% 65%)">${escHtml(step.error)}</div>` : ""}
<div class="flex justify-between text-xs text-muted mt-1">
<span>Chi phí: $${((step.executionCostCents ?? 0)/100).toFixed(4)}</span>
${step.completedAt ? `<span>${Math.round((new Date(step.completedAt)-new Date(step.startedAt))/1000)}s</span>` : ""}
</div>
</div>
</div>`;
}).join("") ?? ""}
</div>
<!-- Deliverables -->
${detail.status === "completed" ? `
<h3 class="font-semibold text-sm">📄 Deliverables</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
${detail.steps?.filter(s => s.status === "completed").map(step => {
const file = `${step.role.toLowerCase().replace(/\s+/g,"_")}_output.md`;
return `<div class="card p-4 flex items-center justify-between gap-3">
<div>
<p class="font-semibold text-xs">${escHtml(step.agentName)}</p>
<p class="text-xs font-mono text-muted">${escHtml(file)}</p>
</div>
<a href="/api/companies/${cid}/shared/${encodeURIComponent(file)}" target="_blank" class="btn btn-outline btn-sm">Xem →</a>
</div>`;
}).join("") ?? ""}
</div>
` : ""}
</div>
</div>`;
}
function showRunModal(cid, onSuccess) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Chạy Pipeline mới</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Mô tả task (Project Brief) *</label>
<textarea class="input" id="r-brief" rows="4" placeholder="Viết bài blog về AI, làm chiến dịch marketing tháng 12..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">▶ Chạy Pipeline</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const brief = backdrop.querySelector("#r-brief").value.trim();
if (!brief) return toast("Cần nhập mô tả task", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true; btn.textContent = "Đang gửi...";
try {
await pipelineRunsApi.run(cid, { project_brief: brief });
toast("Pipeline đã được khởi động!");
close();
await onSuccess();
} catch (err) { toast(err.message, "error"); btn.disabled = false; btn.textContent = "▶ Chạy Pipeline"; }
};
}
function noCompany() {
return `<div class="empty-state"><h3>Chưa chọn phòng ban</h3><p><a href="#companies" style="color:hsl(var(--primary))">Chọn phòng ban</a> trước.</p></div>`;
}
import { routinesApi } from "../api.js";
import { relativeTime, escHtml, toast, confirmDialog } from "../utils.js";
import { getSelectedCompanyId } from "../app.js";
export async function routinesPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) { container.innerHTML = noCompany(); return; }
const routines = await routinesApi.list(cid).catch(() => []);
container.innerHTML = `
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="font-semibold" style="font-size:16px">Routines / Auto-Schedule (${routines.length})</h1>
<button class="btn btn-primary btn-sm" id="btn-new-routine">+ Tạo Routine</button>
</div>
<div id="routines-list" class="space-y-4"></div>
</div>`;
renderRoutines(container.querySelector("#routines-list"), routines, cid);
container.querySelector("#btn-new-routine").onclick = () => showCreateModal(cid, routines);
}
function renderRoutines(el, routines, cid) {
if (!routines.length) {
el.innerHTML = `<div class="empty-state card" style="padding:48px">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<h3>Chưa có routine nào</h3>
<p>Tạo routine để pipeline tự chạy theo lịch.</p>
</div>`;
return;
}
el.innerHTML = routines.map(r => `
<div class="card p-5" data-id="${r.id}">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-semibold">${escHtml(r.name ?? r.id.slice(0,8))}</h3>
<span class="badge ${r.isEnabled ? "badge-active" : "badge-default"}">${r.isEnabled ? "✓ Bật" : "Tắt"}</span>
${r.cron ? `<span class="badge badge-blue font-mono">${escHtml(r.cron)}</span>` : ""}
</div>
${r.query ? `<p class="text-xs text-muted mt-1 line-clamp-2">${escHtml(r.query)}</p>` : ""}
<div class="flex gap-4 mt-2 text-xs text-muted flex-wrap">
${r.lastRunAt ? `<span>Chạy lần cuối: ${relativeTime(r.lastRunAt)}</span>` : "<span>Chưa chạy lần nào</span>"}
${r.nextRunAt ? `<span>Lần tiếp: ${relativeTime(r.nextRunAt)}</span>` : ""}
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm btn-run-now" data-id="${r.id}">▶ Chạy ngay</button>
<button class="btn btn-outline btn-sm btn-toggle" data-id="${r.id}" data-enabled="${r.isEnabled}">${r.isEnabled ? "⏸ Tắt" : "▶ Bật"}</button>
</div>
</div>
</div>`).join("");
el.querySelectorAll(".btn-run-now").forEach(btn => {
btn.onclick = async () => {
btn.disabled = true;
try {
await routinesApi.run(btn.dataset.id);
toast("Đã kích hoạt routine!");
} catch (err) { toast(err.message, "error"); }
btn.disabled = false;
};
});
el.querySelectorAll(".btn-toggle").forEach(btn => {
btn.onclick = async () => {
const enable = btn.dataset.enabled === "true" ? false : true;
try {
await routinesApi.update(btn.dataset.id, { isEnabled: enable });
toast(enable ? "Đã bật routine" : "Đã tắt routine");
location.hash = "routines";
} catch (err) { toast(err.message, "error"); }
};
});
}
function showCreateModal(cid, routines) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Tạo Routine mới</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tên *</label><input class="input" id="r-name" placeholder="Weekly content"></div>
<div class="form-group">
<label class="form-label">Cron Expression *</label>
<input class="input" id="r-cron" placeholder="0 9 * * 1 (thứ 2 hàng tuần lúc 9am)">
<p class="form-hint">Format: phút giờ ngày tháng ngày-trong-tuần. Ví dụ: <code>0 9 * * 1</code> = thứ 2 lúc 9am</p>
</div>
<div class="form-group"><label class="form-label">Timezone</label><input class="input" id="r-tz" value="Asia/Ho_Chi_Minh"></div>
<div class="form-group"><label class="form-label">Project Brief (nội dung task)</label>
<textarea class="input" id="r-query" rows="3" placeholder="Viết 1 bài blog về xu hướng AI tuần này..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const name = backdrop.querySelector("#r-name").value.trim();
const cron = backdrop.querySelector("#r-cron").value.trim();
if (!name || !cron) return toast("Cần nhập tên và cron", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true;
try {
await routinesApi.create(cid, {
name,
cron,
timezone: backdrop.querySelector("#r-tz").value || "UTC",
query: backdrop.querySelector("#r-query").value || undefined,
isEnabled: true,
});
toast("Đã tạo routine");
close();
location.hash = "routines";
} catch (err) { toast(err.message, "error"); btn.disabled = false; }
};
}
function noCompany() {
return `<div class="empty-state"><h3>Chưa chọn phòng ban</h3><p><a href="#companies" style="color:hsl(var(--primary))">Chọn phòng ban</a> trước.</p></div>`;
}
// Hash-based SPA router
const routes = new Map();
let currentRoute = null;
export function registerRoute(hash, handler) {
routes.set(hash, handler);
}
export function navigate(hash) {
window.location.hash = hash;
}
export function getCurrentRoute() { return currentRoute; }
async function handleRoute() {
const hash = window.location.hash.replace("#", "") || "companies";
const [routeName, ...params] = hash.split("/");
const handler = routes.get(routeName) ?? routes.get("404");
if (!handler) return;
currentRoute = routeName;
// Update active nav item
document.querySelectorAll(".nav-item").forEach((el) => {
el.classList.toggle("active", el.dataset.route === routeName);
});
// Render into #content
const content = document.getElementById("content");
content.innerHTML = `<div class="flex items-center gap-3" style="padding:40px;color:hsl(240 5% 64.9%)">
<div class="spinner"></div> Đang tải...
</div>`;
try {
await handler(params, content);
} catch (err) {
content.innerHTML = `<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<h3>Lỗi tải trang</h3>
<p>${err.message}</p>
</div>`;
console.error(err);
}
}
export function initRouter() {
window.addEventListener("hashchange", handleRoute);
handleRoute();
}
// Utility helpers — mirrors frontend/src/lib/utils.ts + timeAgo.ts
export function formatCents(cents) {
if (!cents && cents !== 0) return "$0.00";
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
}
export function relativeTime(dateStr) {
if (!dateStr) return "—";
const d = new Date(dateStr);
const now = Date.now();
const diff = now - d.getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s trước`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}ph trước`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h trước`;
const day = Math.floor(h / 24);
if (day < 30) return `${day}n trước`;
return d.toLocaleDateString("vi-VN");
}
export function timeAgo(dateStr) { return relativeTime(dateStr); }
export function truncate(str, n = 60) {
if (!str) return "";
return str.length > n ? str.slice(0, n) + "…" : str;
}
export function escHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
export function statusBadge(status) {
const cls = {
active: "badge-active", running: "badge-running", idle: "badge-idle",
paused: "badge-paused", error: "badge-error", terminated: "badge-default",
done: "badge-done", in_progress: "badge-in_progress", todo: "badge-todo",
cancelled: "badge-cancelled", completed: "badge-success", failed: "badge-failed",
}[status] ?? "badge-default";
const label = {
active: "Hoạt động", running: "Đang chạy", idle: "Rảnh", paused: "Tạm dừng",
error: "Lỗi", terminated: "Đã kết thúc", done: "Xong", in_progress: "Đang làm",
todo: "Cần làm", cancelled: "Huỷ", completed: "Hoàn thành", failed: "Thất bại",
}[status] ?? status;
return `<span class="badge ${cls}">${escHtml(label)}</span>`;
}
export function statusDot(status) {
const cls = { active: "dot-active", running: "dot-running pulse", error: "dot-error",
paused: "dot-paused", idle: "dot-idle", terminated: "dot-terminated" }[status] ?? "dot-default";
return `<span class="dot ${cls}"></span>`;
}
// Toast notification
export function toast(msg, type = "success") {
const container = document.getElementById("toast-container") ?? createToastContainer();
const el = document.createElement("div");
el.className = `toast toast-${type}`;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 3500);
}
function createToastContainer() {
const el = document.createElement("div");
el.id = "toast-container";
document.body.appendChild(el);
return el;
}
// Generic confirm modal
export function confirmDialog(msg) {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal" style="max-width:380px">
<div class="modal-body" style="padding:24px">
<p style="font-size:14px;margin-bottom:16px">${escHtml(msg)}</p>
<div class="flex gap-2" style="justify-content:flex-end">
<button class="btn btn-outline" id="conf-cancel">Huỷ</button>
<button class="btn btn-destructive" id="conf-ok">Xác nhận</button>
</div>
</div>
</div>`;
document.body.appendChild(backdrop);
backdrop.querySelector("#conf-cancel").onclick = () => { backdrop.remove(); resolve(false); };
backdrop.querySelector("#conf-ok").onclick = () => { backdrop.remove(); resolve(true); };
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) { backdrop.remove(); resolve(false); } });
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment