feat: Add OpsAgent, whatsapp-gateway, and hq-dashboard
This commit is contained in:
parent
65b2316dad
commit
5fb2afec1d
|
|
@ -0,0 +1,75 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ops-worker
|
||||
namespace: ai-agents
|
||||
labels:
|
||||
app.kubernetes.io/name: ops-worker
|
||||
agentic-os.io/agent: ops-agent
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: ops-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: ops-worker
|
||||
agentic-os.io/agent: ops-agent
|
||||
spec:
|
||||
serviceAccountName: default
|
||||
initContainers:
|
||||
- name: git-clone
|
||||
image: alpine/git
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- "git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace && cp -r /workspace/agents/ops_agent/* /app/"
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
containers:
|
||||
- name: ops-worker
|
||||
image: python:3.11-slim
|
||||
imagePullPolicy: Always
|
||||
command: ["sh", "-c", "pip install -r /app/requirements.txt && cd /app && python -m ops_agent.temporal.worker"]
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
env:
|
||||
- name: TEMPORAL_ADDRESS
|
||||
value: "temporal-frontend.ai-core.svc.cluster.local:7233"
|
||||
- name: TEMPORAL_NAMESPACE
|
||||
value: "default"
|
||||
- name: PYTHONUNBUFFERED
|
||||
value: "1"
|
||||
- name: OPS_TASK_QUEUE
|
||||
value: "ops"
|
||||
- name: MOCK_OPS
|
||||
value: "false" # Set to false to run actual checks in the cluster
|
||||
- name: LOCAL_WHATSAPP_URL
|
||||
value: "http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message"
|
||||
- name: LITELLM_BASE_URL
|
||||
value: "http://ollama.ai-core.svc.cluster.local:11434/v1"
|
||||
- name: GUMBO_LLM_MODEL
|
||||
value: "qwen2.5:3b"
|
||||
- name: LITELLM_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gumbo-litellm
|
||||
key: api_key
|
||||
- name: LANGGRAPH_CHECKPOINT_URI
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gumbo-checkpoint-db
|
||||
key: uri
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
volumes:
|
||||
- name: app-code
|
||||
emptyDir: {}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: whatsapp-gateway
|
||||
namespace: ai-agents
|
||||
labels:
|
||||
app: whatsapp-gateway
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: whatsapp-gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: whatsapp-gateway
|
||||
spec:
|
||||
serviceAccountName: default
|
||||
initContainers:
|
||||
- name: git-clone
|
||||
image: alpine/git
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- "git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace && cp -r /workspace/whatsapp-gateway/* /app/"
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
containers:
|
||||
- name: whatsapp-gateway
|
||||
image: node:18-alpine
|
||||
# Install dependencies needed by Puppeteer inside Alpine
|
||||
# whatsapp-web.js requires Chromium
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- "apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont && cd /app && npm install && PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser node server.js"
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
ports:
|
||||
- containerPort: 5001
|
||||
resources:
|
||||
requests:
|
||||
cpu: 300m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "1.5"
|
||||
memory: 1.5Gi
|
||||
volumes:
|
||||
- name: app-code
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: whatsapp-gateway
|
||||
namespace: ai-agents
|
||||
spec:
|
||||
ports:
|
||||
- port: 5001
|
||||
targetPort: 5001
|
||||
selector:
|
||||
app: whatsapp-gateway
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
FROM python:3.11-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PYTHONPATH=/app
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
COPY ops_agent /app/ops_agent
|
||||
CMD ["python", "-m", "ops_agent.run"]
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
FROM python:3.11-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PYTHONPATH=/app
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
COPY ops_agent /app/ops_agent
|
||||
CMD ["python", "-m", "ops_agent.temporal.worker"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
# OpsAgent package
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import os
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.graph import StateGraph, END
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
|
||||
from ops_agent.tools import (
|
||||
k8s_get_status, k8s_get_logs, k8s_restart_pod, k8s_scale_deployment,
|
||||
proxmox_get_status, proxmox_get_disk_space, proxmox_reboot_container, proxmox_clean_disk
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OpsState(TypedDict):
|
||||
target_type: str # 'kubernetes' or 'proxmox'
|
||||
target_id: str # e.g., 'default/web-pod' or VMID '101'
|
||||
status_info: str # status output
|
||||
logs: str # logs output
|
||||
diagnosis: str # LLM diagnosis of issues
|
||||
proposed_action: str # Proposed action: 'restart_pod', 'scale_deployment', 'reboot_container', 'clean_disk', 'none'
|
||||
proposed_action_details: str # E.g., pod name, deployment name, or container ID
|
||||
is_approved: bool # Has user approved it?
|
||||
action_result: str # Outcome of the action execution
|
||||
|
||||
def get_llm():
|
||||
"""Initializes LLM using environment variables, matching the Gumbo setup."""
|
||||
api_key = os.environ.get("LITELLM_API_KEY") or os.environ.get("OPENAI_API_KEY", "mock-api-key")
|
||||
base_url = os.environ.get("LITELLM_BASE_URL") or os.environ.get("OPENAI_BASE_URL", "http://localhost:4000")
|
||||
model = os.environ.get("OPS_LLM_MODEL") or os.environ.get("GUMBO_LLM_MODEL", "ollama-qwen")
|
||||
|
||||
logger.info(f"Initializing ChatOpenAI with model={model}, base_url={base_url}")
|
||||
return ChatOpenAI(
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
temperature=0.1
|
||||
)
|
||||
|
||||
async def _fetch_status_and_logs(state: OpsState) -> dict:
|
||||
target_type = state.get("target_type", "").lower()
|
||||
target_id = state.get("target_id", "")
|
||||
|
||||
status_info = ""
|
||||
logs = ""
|
||||
|
||||
if target_type == "kubernetes":
|
||||
# target_id pattern: "namespace/pod-name" or just "pod-name" (assumes default namespace)
|
||||
parts = target_id.split("/")
|
||||
if len(parts) == 2:
|
||||
ns, pod = parts[0], parts[1]
|
||||
else:
|
||||
ns, pod = "default", parts[0]
|
||||
|
||||
status_info = k8s_get_status(ns, pod)
|
||||
logs = k8s_get_logs(ns, pod)
|
||||
|
||||
elif target_type == "proxmox":
|
||||
# target_id is LXC VMID (e.g. "101")
|
||||
status_info = proxmox_get_status(target_id)
|
||||
logs = proxmox_get_disk_space(target_id)
|
||||
|
||||
else:
|
||||
status_info = f"Unknown target type: {target_type}"
|
||||
logs = "No logs available."
|
||||
|
||||
return {"status_info": status_info, "logs": logs}
|
||||
|
||||
async def _diagnose_issue(state: OpsState) -> dict:
|
||||
llm = get_llm()
|
||||
system_msg = SystemMessage(
|
||||
content=(
|
||||
"You are OpsAgent, a senior DevOps engineer and Proxmox/Kubernetes site reliability assistant. "
|
||||
"Analyze the provided status info and logs, identify the root cause of any problems, and return "
|
||||
"a concise Markdown diagnosis."
|
||||
)
|
||||
)
|
||||
user_content = (
|
||||
f"Target Type: {state['target_type']}\n"
|
||||
f"Target ID: {state['target_id']}\n\n"
|
||||
f"=== STATUS INFO ===\n{state['status_info']}\n\n"
|
||||
f"=== LOGS / RESOURCE USAGE ===\n{state['logs']}\n"
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await llm.ainvoke([system_msg, HumanMessage(content=user_content)])
|
||||
diagnosis = resp.content if isinstance(resp.content, str) else str(resp.content)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM call failed in diagnosis: {e}")
|
||||
diagnosis = f"LLM error: Failed to diagnose log. Raw error: {str(e)}"
|
||||
|
||||
return {"diagnosis": diagnosis}
|
||||
|
||||
async def _propose_remediation(state: OpsState) -> dict:
|
||||
llm = get_llm()
|
||||
system_msg = SystemMessage(
|
||||
content=(
|
||||
"You are OpsAgent. Based on the diagnosis, propose a specific remediation action from the following list:\n"
|
||||
"- restart_pod (Kubernetes pods crash/hangs)\n"
|
||||
"- scale_deployment (K8s capacity issues)\n"
|
||||
"- reboot_container (Proxmox container stopped/unresponsive)\n"
|
||||
"- clean_disk (Proxmox/LXC container running out of disk space: Use% >= 90%)\n"
|
||||
"- none (No actions required, target is running fine)\n\n"
|
||||
"Respond ONLY with a JSON object: "
|
||||
'{"action": "restart_pod"|"scale_deployment"|"reboot_container"|"clean_disk"|"none", '
|
||||
'"details": "brief reason or extra target parameter"}'
|
||||
)
|
||||
)
|
||||
user_content = f"Diagnosis:\n{state['diagnosis']}"
|
||||
|
||||
action = "none"
|
||||
details = "No action proposed."
|
||||
|
||||
try:
|
||||
resp = await llm.ainvoke([system_msg, HumanMessage(content=user_content)])
|
||||
content = resp.content if isinstance(resp.content, str) else str(resp.content)
|
||||
# Parse JSON
|
||||
import json
|
||||
# Strip potential markdown fences
|
||||
clean_content = content.replace("```json", "").replace("```", "").strip()
|
||||
data = json.loads(clean_content)
|
||||
action = data.get("action", "none")
|
||||
details = data.get("details", "")
|
||||
|
||||
# Dispatch WhatsApp Alert to phone if an anomaly/remediation is flagged
|
||||
if action != "none":
|
||||
try:
|
||||
from ops_agent.tools import send_whatsapp_alert
|
||||
alert_msg = (
|
||||
f"⚠️ *OPSAGENT THREAT WARNING* ⚠️\n\n"
|
||||
f"*Target:* {state['target_type'].upper()} ({state['target_id']})\n"
|
||||
f"*Diagnosis:* {details}\n"
|
||||
f"*Proposed Action:* {action.replace('_', ' ').upper()}\n\n"
|
||||
f"Login to the Agentic OS Dashboard to authorize or reject this mitigation."
|
||||
)
|
||||
send_whatsapp_alert(alert_msg)
|
||||
except Exception as ex:
|
||||
logger.error(f"Could not dispatch WhatsApp alert: {ex}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to propose action via LLM: {e}")
|
||||
|
||||
return {"proposed_action": action, "proposed_action_details": details}
|
||||
|
||||
async def _execute_remediation(state: OpsState) -> dict:
|
||||
if not state.get("is_approved", False):
|
||||
return {"action_result": "Remediation action skipped: Approval was not provided."}
|
||||
|
||||
action = state.get("proposed_action", "none")
|
||||
target_type = state.get("target_type", "")
|
||||
target_id = state.get("target_id", "")
|
||||
|
||||
result = "No action executed."
|
||||
|
||||
try:
|
||||
if target_type == "kubernetes":
|
||||
parts = target_id.split("/")
|
||||
ns = parts[0] if len(parts) == 2 else "default"
|
||||
pod = parts[1] if len(parts) == 2 else parts[0]
|
||||
|
||||
if action == "restart_pod":
|
||||
result = k8s_restart_pod(ns, pod)
|
||||
elif action == "scale_deployment":
|
||||
# Default scale to 2 for demo purposes
|
||||
result = k8s_scale_deployment(ns, pod, 2)
|
||||
else:
|
||||
result = f"Action '{action}' is not supported for Kubernetes target."
|
||||
|
||||
elif target_type == "proxmox":
|
||||
if action == "reboot_container":
|
||||
result = proxmox_reboot_container(target_id)
|
||||
elif action == "clean_disk":
|
||||
result = proxmox_clean_disk(target_id)
|
||||
else:
|
||||
result = f"Action '{action}' is not supported for Proxmox container VMID {target_id}."
|
||||
else:
|
||||
result = f"Unknown target type: {target_type}."
|
||||
|
||||
except Exception as e:
|
||||
result = f"Error during execution: {str(e)}"
|
||||
|
||||
return {"action_result": result}
|
||||
|
||||
|
||||
def build_graph_builder() -> StateGraph:
|
||||
builder = StateGraph(OpsState)
|
||||
builder.add_node("fetch_status", _fetch_status_and_logs)
|
||||
builder.add_node("diagnose", _diagnose_issue)
|
||||
builder.add_node("propose_action", _propose_remediation)
|
||||
builder.add_node("execute_action", _execute_remediation)
|
||||
|
||||
builder.set_entry_point("fetch_status")
|
||||
builder.add_edge("fetch_status", "diagnose")
|
||||
builder.add_edge("diagnose", "propose_action")
|
||||
builder.add_edge("propose_action", "execute_action")
|
||||
builder.add_edge("execute_action", END)
|
||||
|
||||
return builder
|
||||
|
||||
async def run_ops_graph(target_type: str, target_id: str, is_approved: bool = False, conn_string: str = None) -> OpsState:
|
||||
builder = build_graph_builder()
|
||||
|
||||
initial_state = {
|
||||
"target_type": target_type,
|
||||
"target_id": target_id,
|
||||
"status_info": "",
|
||||
"logs": "",
|
||||
"diagnosis": "",
|
||||
"proposed_action": "none",
|
||||
"proposed_action_details": "",
|
||||
"is_approved": is_approved,
|
||||
"action_result": ""
|
||||
}
|
||||
|
||||
if conn_string:
|
||||
async with AsyncPostgresSaver.from_conn_string(conn_string) as checkpointer:
|
||||
await checkpointer.setup()
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
return await graph.ainvoke(
|
||||
initial_state,
|
||||
config={"configurable": {"thread_id": f"ops-{target_type}-{target_id}"}},
|
||||
)
|
||||
else:
|
||||
graph = builder.compile()
|
||||
return await graph.ainvoke(initial_state)
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import asyncio
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="Run OpsAgent diagnostics CLI tool.")
|
||||
parser.add_argument("--target-type", required=True, choices=["kubernetes", "proxmox"], help="Target platform to check.")
|
||||
parser.add_argument("--target-id", required=True, help="Specific target identifier (e.g., namespace/pod-name or Proxmox container VMID).")
|
||||
parser.add_argument("--approve", action="store_true", help="Approve and execute proposed remediation.")
|
||||
parser.add_argument("--real", action="store_true", help="Run real CLI commands instead of mocks.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Configure logging
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
# Set mock mode
|
||||
if args.real:
|
||||
os.environ["MOCK_OPS"] = "false"
|
||||
print("Executing in REAL mode...")
|
||||
else:
|
||||
os.environ["MOCK_OPS"] = "true"
|
||||
print("Executing in MOCK mode...")
|
||||
|
||||
# We need to set mock key environment variables if they aren't present
|
||||
if "LITELLM_API_KEY" not in os.environ:
|
||||
os.environ["LITELLM_API_KEY"] = "mock-key"
|
||||
if "LITELLM_BASE_URL" not in os.environ:
|
||||
os.environ["LITELLM_BASE_URL"] = "http://localhost:4000" # fallback
|
||||
|
||||
from ops_agent.graph import run_ops_graph
|
||||
|
||||
print(f"Starting OpsAgent check for target type: {args.target_type}, ID: {args.target_id}...")
|
||||
|
||||
try:
|
||||
result = await run_ops_graph(
|
||||
target_type=args.target_type,
|
||||
target_id=args.target_id,
|
||||
is_approved=args.approve
|
||||
)
|
||||
|
||||
print("\n==================================================")
|
||||
print(" RESULTS ")
|
||||
print("==================================================")
|
||||
print(f"Target Check: {result['target_type'].upper()} ({result['target_id']})")
|
||||
print("\n--- DIAGNOSIS ---")
|
||||
print(result["diagnosis"])
|
||||
print("\n--- PROPOSED ACTION ---")
|
||||
print(f"Action: {result['proposed_action']}")
|
||||
print(f"Details: {result['proposed_action_details']}")
|
||||
print("\n--- EXECUTION RESULT ---")
|
||||
print(result["action_result"])
|
||||
print("==================================================")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nExecution failed: {str(e)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Temporal package initialization for OpsAgent
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from temporalio import activity
|
||||
|
||||
from ops_agent.graph import run_ops_graph
|
||||
|
||||
@activity.defn
|
||||
async def run_ops_diagnose_activity(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Runs status check, log fetch, and LLM diagnosis."""
|
||||
target_type = params["target_type"]
|
||||
target_id = params["target_id"]
|
||||
|
||||
# We do NOT run remediation yet, just diagnosis and proposed action
|
||||
state = await run_ops_graph(
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
is_approved=False
|
||||
)
|
||||
|
||||
return {
|
||||
"target_type": state["target_type"],
|
||||
"target_id": state["target_id"],
|
||||
"status_info": state["status_info"],
|
||||
"logs": state["logs"],
|
||||
"diagnosis": state["diagnosis"],
|
||||
"proposed_action": state["proposed_action"],
|
||||
"proposed_action_details": state["proposed_action_details"]
|
||||
}
|
||||
|
||||
@activity.defn
|
||||
async def run_ops_remediate_activity(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Runs remediation action once approved."""
|
||||
target_type = params["target_type"]
|
||||
target_id = params["target_id"]
|
||||
proposed_action = params["proposed_action"]
|
||||
|
||||
from ops_agent.graph import build_graph_builder
|
||||
builder = build_graph_builder()
|
||||
graph = builder.compile()
|
||||
|
||||
# Run the graph directly starting with the execute node by feeding the state
|
||||
state = await graph.ainvoke({
|
||||
"target_type": target_type,
|
||||
"target_id": target_id,
|
||||
"status_info": params.get("status_info", ""),
|
||||
"logs": params.get("logs", ""),
|
||||
"diagnosis": params.get("diagnosis", ""),
|
||||
"proposed_action": proposed_action,
|
||||
"proposed_action_details": params.get("proposed_action_details", ""),
|
||||
"is_approved": True,
|
||||
"action_result": ""
|
||||
})
|
||||
|
||||
return {
|
||||
"action_result": state["action_result"]
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from temporalio.client import Client
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from ops_agent.temporal.workflows import OpsRemediateWorkflow
|
||||
from ops_agent.temporal.activities import (
|
||||
run_ops_diagnose_activity,
|
||||
run_ops_remediate_activity
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def _main() -> None:
|
||||
address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233")
|
||||
namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
|
||||
task_queue = os.environ.get("OPS_TASK_QUEUE", "ops")
|
||||
|
||||
logger.info(f"Connecting to Temporal cluster at {address} (Namespace: {namespace})...")
|
||||
try:
|
||||
client = await Client.connect(address, namespace=namespace)
|
||||
logger.info("Successfully connected to Temporal!")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Temporal: {e}")
|
||||
return
|
||||
|
||||
worker = Worker(
|
||||
client,
|
||||
task_queue=task_queue,
|
||||
workflows=[OpsRemediateWorkflow],
|
||||
activities=[
|
||||
run_ops_diagnose_activity,
|
||||
run_ops_remediate_activity,
|
||||
],
|
||||
)
|
||||
logger.info(f"Starting OpsAgent worker on queue '{task_queue}'...")
|
||||
await worker.run()
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(_main())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from temporalio import workflow
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from ops_agent.temporal.activities import (
|
||||
run_ops_diagnose_activity,
|
||||
run_ops_remediate_activity
|
||||
)
|
||||
|
||||
@workflow.defn
|
||||
class OpsRemediateWorkflow:
|
||||
"""Orchestrates OpsAgent diagnosis, waits for human operator approval signal, and executes remediation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._approved = False
|
||||
self._rejected = False
|
||||
|
||||
@workflow.signal
|
||||
def approve(self) -> None:
|
||||
self._approved = True
|
||||
|
||||
@workflow.signal
|
||||
def reject(self) -> None:
|
||||
self._rejected = True
|
||||
|
||||
@workflow.run
|
||||
async def run(self, target_type: str, target_id: str) -> dict:
|
||||
# Step 1: Diagnose issues
|
||||
params = {"target_type": target_type, "target_id": target_id}
|
||||
diagnose_res = await workflow.execute_activity(
|
||||
run_ops_diagnose_activity,
|
||||
params,
|
||||
start_to_close_timeout=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
proposed_action = diagnose_res.get("proposed_action", "none")
|
||||
|
||||
# Step 2: Check if remediation is proposed. If 'none', exit.
|
||||
if proposed_action == "none":
|
||||
return {
|
||||
"status": "completed",
|
||||
"diagnosis": diagnose_res["diagnosis"],
|
||||
"action_proposed": "none",
|
||||
"action_executed": "none",
|
||||
"result": "No problems detected. No action needed."
|
||||
}
|
||||
|
||||
# Step 3: Approval Gate (wait for approval signal or reject signal or timeout)
|
||||
# Timeout after 24 hours (or shorter in test/production depending on requirements)
|
||||
approval_timeout = timedelta(hours=2)
|
||||
|
||||
workflow.logger.info(
|
||||
f"Action proposed: {proposed_action}. Waiting for approval signal on workflow ID: {workflow.info().workflow_id}"
|
||||
)
|
||||
|
||||
# Wait for approval condition
|
||||
try:
|
||||
is_condition_met = await workflow.wait_condition(
|
||||
lambda: self._approved or self._rejected,
|
||||
timeout=approval_timeout
|
||||
)
|
||||
except Exception:
|
||||
# wait_condition times out
|
||||
is_condition_met = False
|
||||
|
||||
if not is_condition_met:
|
||||
return {
|
||||
"status": "timed_out",
|
||||
"diagnosis": diagnose_res["diagnosis"],
|
||||
"action_proposed": proposed_action,
|
||||
"action_executed": "none",
|
||||
"result": "Timed out waiting for administrator approval."
|
||||
}
|
||||
|
||||
if self._rejected:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"diagnosis": diagnose_res["diagnosis"],
|
||||
"action_proposed": proposed_action,
|
||||
"action_executed": "none",
|
||||
"result": "Remediation action rejected by administrator."
|
||||
}
|
||||
|
||||
# Step 4: Execute remediation
|
||||
remed_params = {**diagnose_res, "is_approved": True}
|
||||
exec_res = await workflow.execute_activity(
|
||||
run_ops_remediate_activity,
|
||||
remed_params,
|
||||
start_to_close_timeout=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "remediated",
|
||||
"diagnosis": diagnose_res["diagnosis"],
|
||||
"action_proposed": proposed_action,
|
||||
"action_executed": proposed_action,
|
||||
"result": exec_res["action_result"]
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def run_local_command(args):
|
||||
"""Executes a command on the local system."""
|
||||
try:
|
||||
res = subprocess.run(args, capture_output=True, text=True, timeout=15)
|
||||
if res.returncode == 0:
|
||||
return res.stdout
|
||||
else:
|
||||
return f"Error executing command: {res.stderr}"
|
||||
except Exception as e:
|
||||
return f"Failed to run command locally: {str(e)}"
|
||||
|
||||
def run_ssh_command(host, username, command, password=None, key_filename=None):
|
||||
"""Runs a command via SSH on a remote host (e.g., Proxmox node)."""
|
||||
try:
|
||||
import paramiko
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
# Connect using password or key
|
||||
ssh.connect(
|
||||
hostname=host,
|
||||
username=username,
|
||||
password=password,
|
||||
key_filename=key_filename,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
stdin, stdout, stderr = ssh.exec_command(command)
|
||||
out = stdout.read().decode('utf-8')
|
||||
err = stderr.read().decode('utf-8')
|
||||
ssh.close()
|
||||
|
||||
if err and not out:
|
||||
return f"SSH Error: {err}"
|
||||
return out
|
||||
except Exception as e:
|
||||
return f"SSH connection failed: {str(e)}"
|
||||
|
||||
# --- Kubernetes Tools ---
|
||||
|
||||
def k8s_get_status(namespace: str, pod_name: str = None) -> str:
|
||||
"""Gets Kubernetes pod status in the specified namespace."""
|
||||
if os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock K8s] Fetching status...")
|
||||
if pod_name:
|
||||
return f"NAME: {pod_name}\nREADY: 0/1\nSTATUS: CrashLoopBackOff\nRESTARTS: 5\nAGE: 2h"
|
||||
return f"NAME\tREADY\tSTATUS\tRESTARTS\tAGE\nweb-pod-xyz\t0/1\tCrashLoopBackOff\t5\t2h\ndb-pod-abc\t1/1\tRunning\t0\t5d"
|
||||
|
||||
# Real execution
|
||||
cmd = ["kubectl", "get", "pods", "-n", namespace]
|
||||
if pod_name:
|
||||
cmd.append(pod_name)
|
||||
return run_local_command(cmd)
|
||||
|
||||
def k8s_get_logs(namespace: str, pod_name: str) -> str:
|
||||
"""Gets Kubernetes pod logs."""
|
||||
if os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock K8s] Fetching logs...")
|
||||
return (
|
||||
"2026-05-29 01:00:00 [INFO] Starting up server...\n"
|
||||
"2026-05-29 01:00:01 [INFO] Connecting to database...\n"
|
||||
"2026-05-29 01:00:03 [ERROR] Database connection failed: Connection refused by 10.96.20.10:5432\n"
|
||||
"2026-05-29 01:00:03 [FATAL] Exiting server due to database unavailability."
|
||||
)
|
||||
|
||||
# Real execution
|
||||
cmd = ["kubectl", "logs", pod_name, "-n", namespace, "--tail=100"]
|
||||
return run_local_command(cmd)
|
||||
|
||||
def k8s_restart_pod(namespace: str, pod_name: str) -> str:
|
||||
"""Restarts a Kubernetes pod by deleting it (K8s will recreate it)."""
|
||||
if os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock K8s] Restarting pod...")
|
||||
return f"Mock: Pod {namespace}/{pod_name} successfully deleted (restarted)."
|
||||
|
||||
cmd = ["kubectl", "delete", "pod", pod_name, "-n", namespace]
|
||||
return run_local_command(cmd)
|
||||
|
||||
def k8s_scale_deployment(namespace: str, deployment: str, replicas: int) -> str:
|
||||
"""Scales a deployment to the target number of replicas."""
|
||||
if os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock K8s] Scaling deployment...")
|
||||
return f"Mock: Deployment {namespace}/{deployment} successfully scaled to {replicas} replicas."
|
||||
|
||||
cmd = ["kubectl", "scale", f"deployment/{deployment}", f"--replicas={replicas}", "-n", namespace]
|
||||
return run_local_command(cmd)
|
||||
|
||||
|
||||
# --- Proxmox Tools ---
|
||||
|
||||
def get_proxmox_ssh_creds():
|
||||
"""Retrieves SSH configs for Proxmox node from environment."""
|
||||
return {
|
||||
"host": os.environ.get("PROXMOX_SSH_HOST"),
|
||||
"user": os.environ.get("PROXMOX_SSH_USER", "root"),
|
||||
"password": os.environ.get("PROXMOX_SSH_PASSWORD"),
|
||||
"key": os.environ.get("PROXMOX_SSH_KEY")
|
||||
}
|
||||
|
||||
def proxmox_get_status(vmid: str) -> str:
|
||||
"""Checks LXC container status in Proxmox."""
|
||||
creds = get_proxmox_ssh_creds()
|
||||
if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock Proxmox] Fetching status for VMID %s...", vmid)
|
||||
return f"status: stopped\nname: app-container-{vmid}\nmem: 2048MB\ndisk: 20GB\nrunning: false"
|
||||
|
||||
cmd = f"pct status {vmid}"
|
||||
return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"])
|
||||
|
||||
def proxmox_get_disk_space(vmid: str) -> str:
|
||||
"""Queries disk space for the LXC container."""
|
||||
creds = get_proxmox_ssh_creds()
|
||||
if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock Proxmox] Fetching disk space for VMID %s...", vmid)
|
||||
return (
|
||||
"Filesystem Size Used Avail Use% Mounted on\n"
|
||||
"/dev/loop0 20G 19G 0.2G 99% /\n"
|
||||
"none 4.0G 0 4.0G 0% /dev"
|
||||
)
|
||||
|
||||
cmd = f"pct exec {vmid} -- df -h /"
|
||||
return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"])
|
||||
|
||||
def proxmox_reboot_container(vmid: str) -> str:
|
||||
"""Reboots a Proxmox LXC container."""
|
||||
creds = get_proxmox_ssh_creds()
|
||||
if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock Proxmox] Rebooting LXC container %s...", vmid)
|
||||
return f"Mock: Container {vmid} successfully rebooted."
|
||||
|
||||
cmd = f"pct reboot {vmid}"
|
||||
return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"])
|
||||
|
||||
def proxmox_clean_disk(vmid: str) -> str:
|
||||
"""Performs common disk cleanup operations inside a Proxmox container."""
|
||||
creds = get_proxmox_ssh_creds()
|
||||
if not creds["host"] or os.environ.get("MOCK_OPS", "true").lower() == "true":
|
||||
logger.info("[Mock Proxmox] Cleaning up disk inside container %s...", vmid)
|
||||
return "Mock: Ran 'apt-get clean' and removed temporary files. Disk usage reduced to 75%."
|
||||
|
||||
# Perform cleanup inside LXC
|
||||
cleanup_cmd = "pct exec {} -- bash -c 'apt-get clean && rm -rf /tmp/* && rm -rf /var/tmp/*'"
|
||||
cmd = cleanup_cmd.format(vmid)
|
||||
return run_ssh_command(creds["host"], creds["user"], cmd, password=creds["password"], key_filename=creds["key"])
|
||||
|
||||
def send_whatsapp_alert(message_body: str) -> str:
|
||||
"""Dispatches a WhatsApp alert using the local gateway (if running) or falls back to Twilio."""
|
||||
admin_number = os.environ.get("ADMIN_WHATSAPP_NUMBER") or os.environ.get("PHYSICIAN_WHATSAPP_NUMBER", "whatsapp:+14085505485")
|
||||
|
||||
# 1. Attempt local WhatsApp Web Gateway first (if enabled/running)
|
||||
use_local = os.environ.get("USE_LOCAL_WHATSAPP", "true").lower() == "true"
|
||||
gateway_url = os.environ.get("LOCAL_WHATSAPP_URL", "http://localhost:5001/send-message")
|
||||
|
||||
if use_local:
|
||||
try:
|
||||
import urllib.request
|
||||
import json
|
||||
payload = json.dumps({"to": admin_number, "message": message_body}).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
gateway_url,
|
||||
data=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST'
|
||||
)
|
||||
# Short timeout to avoid hanging if gateway is offline
|
||||
with urllib.request.urlopen(req, timeout=8) as response:
|
||||
res_data = json.loads(response.read().decode('utf-8'))
|
||||
if res_data.get("success"):
|
||||
logger.info(f"[OpsAgent] Local WhatsApp alert dispatched successfully to: {res_data.get('recipient')}")
|
||||
return f"Alert sent via local gateway (JID: {res_data.get('recipient')})"
|
||||
except Exception as e:
|
||||
logger.warning(f"[OpsAgent] Local WhatsApp Web Gateway failed or is offline: {str(e)}. Falling back to Twilio...")
|
||||
|
||||
# 2. Twilio Fallback
|
||||
account_sid = os.environ.get("TWILIO_ACCOUNT_SID", "AC601e1962dc8417ddb3ae53cd8d865020")
|
||||
auth_token = os.environ.get("TWILIO_AUTH_TOKEN", "08a68571a251d6d62f60dc83fc725b5a")
|
||||
twilio_from = os.environ.get("TWILIO_WHATSAPP_FROM") or os.environ.get("TWILIO_FROM_NUMBER", "whatsapp:+14155238886")
|
||||
|
||||
if not (account_sid and auth_token and twilio_from and admin_number):
|
||||
logger.warning("[OpsAgent] Twilio credentials or admin number not set. Skipping WhatsApp alert.")
|
||||
return "Twilio credentials not configured."
|
||||
|
||||
try:
|
||||
from twilio.rest import Client
|
||||
client = Client(account_sid, auth_token)
|
||||
message = client.messages.create(
|
||||
from_=twilio_from,
|
||||
body=message_body,
|
||||
to=admin_number
|
||||
)
|
||||
logger.info(f"[OpsAgent] WhatsApp alert sent successfully! SID: {message.sid}")
|
||||
return f"Alert sent (SID: {message.sid})"
|
||||
except Exception as e:
|
||||
logger.error(f"[OpsAgent] Failed to send WhatsApp alert: {str(e)}")
|
||||
return f"Failed to send alert: {str(e)}"
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
[project]
|
||||
name = "ops-agent"
|
||||
version = "0.1.0"
|
||||
description = "OpsAgent — Infrastructure & DevOps monitoring agent for Proxmox and Kubernetes"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
dependencies = [
|
||||
"langgraph>=0.2.0",
|
||||
"langchain-core>=0.3.0",
|
||||
"langchain-openai>=0.2.0",
|
||||
"temporalio>=1.8.0",
|
||||
"paramiko>=3.4.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic>=2.0.0",
|
||||
"twilio>=8.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ops-worker = "ops_agent.temporal.worker:main"
|
||||
ops-run = "ops_agent.run:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
langgraph>=0.2.0
|
||||
langchain-core>=0.3.0
|
||||
langchain-openai>=0.2.0
|
||||
temporalio>=1.8.0
|
||||
paramiko>=3.4.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic>=2.0.0
|
||||
twilio>=8.0.0
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"""Trigger and control OpsAgent workflows from local machine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from temporalio.client import Client
|
||||
|
||||
async def connect_client():
|
||||
address = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")
|
||||
namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
|
||||
print(f"Connecting to Temporal at {address}...")
|
||||
return await Client.connect(address, namespace=namespace)
|
||||
|
||||
async def start_workflow(args):
|
||||
client = await connect_client()
|
||||
wf_id = f"ops-{args.target_type}-{args.target_id.replace('/', '-')}"
|
||||
|
||||
print(f"Starting OpsRemediateWorkflow for {args.target_type} target '{args.target_id}'...")
|
||||
try:
|
||||
handle = await client.start_workflow(
|
||||
"OpsRemediateWorkflow",
|
||||
args.target_type,
|
||||
args.target_id,
|
||||
id=wf_id,
|
||||
task_queue="ops",
|
||||
)
|
||||
print(f"Workflow started successfully! Workflow ID: {handle.id}")
|
||||
print("You can now monitor it, or run 'approve' / 'reject' command to signal it.")
|
||||
except Exception as e:
|
||||
print(f"Failed to start workflow: {e}")
|
||||
|
||||
async def send_signal(args, signal_type: str):
|
||||
client = await connect_client()
|
||||
wf_id = args.workflow_id
|
||||
|
||||
print(f"Sending '{signal_type}' signal to workflow {wf_id}...")
|
||||
try:
|
||||
handle = client.get_workflow_handle(wf_id)
|
||||
if signal_type == "approve":
|
||||
await handle.signal("approve")
|
||||
else:
|
||||
await handle.signal("reject")
|
||||
print(f"Signal '{signal_type}' sent successfully!")
|
||||
except Exception as e:
|
||||
print(f"Failed to send signal: {e}")
|
||||
|
||||
async def get_result(args):
|
||||
client = await connect_client()
|
||||
wf_id = args.workflow_id
|
||||
|
||||
print(f"Waiting for results of workflow {wf_id}...")
|
||||
try:
|
||||
handle = client.get_workflow_handle(wf_id)
|
||||
result = await handle.result()
|
||||
print("\n=== Workflow Final Result ===")
|
||||
import json
|
||||
print(json.dumps(result, indent=2))
|
||||
except Exception as e:
|
||||
print(f"Failed to retrieve workflow result: {e}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Trigger and control OpsAgent workflows.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# Start command
|
||||
parser_start = subparsers.add_parser("start", help="Start a diagnostics & remediation workflow.")
|
||||
parser_start.add_argument("--target-type", required=True, choices=["kubernetes", "proxmox"])
|
||||
parser_start.add_argument("--target-id", required=True, help="e.g. default/web-pod or VMID like 101")
|
||||
|
||||
# Approve command
|
||||
parser_app = subparsers.add_parser("approve", help="Send approve signal to running workflow.")
|
||||
parser_app.add_argument("--workflow-id", required=True)
|
||||
|
||||
# Reject command
|
||||
parser_rej = subparsers.add_parser("reject", help="Send reject signal to running workflow.")
|
||||
parser_rej.add_argument("--workflow-id", required=True)
|
||||
|
||||
# Result command
|
||||
parser_res = subparsers.add_parser("result", help="Wait for and display workflow final result.")
|
||||
parser_res.add_argument("--workflow-id", required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "start":
|
||||
asyncio.run(start_workflow(args))
|
||||
elif args.command == "approve":
|
||||
asyncio.run(send_signal(args, "approve"))
|
||||
elif args.command == "reject":
|
||||
asyncio.run(send_signal(args, "reject"))
|
||||
elif args.command == "result":
|
||||
asyncio.run(get_result(args))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-primary: #080a10;
|
||||
--bg-secondary: #0f131f;
|
||||
--bg-card: rgba(20, 26, 43, 0.6);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
|
||||
--accent-violet: #8b5cf6;
|
||||
--accent-violet-glow: rgba(139, 92, 246, 0.25);
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-cyan-glow: rgba(6, 182, 212, 0.25);
|
||||
--accent-emerald: #10b981;
|
||||
--accent-emerald-glow: rgba(16, 185, 129, 0.2);
|
||||
--accent-rose: #f43f5e;
|
||||
--accent-rose-glow: rgba(244, 63, 94, 0.2);
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-amber-glow: rgba(245, 158, 11, 0.2);
|
||||
|
||||
--font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
overflow-x: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Glassmorphism utility */
|
||||
.glass {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.glass-interactive {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.glass-interactive:hover {
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 15px rgba(139, 92, 246, 0.05);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Accent Glows */
|
||||
.glow-violet {
|
||||
box-shadow: 0 0 20px var(--accent-violet-glow);
|
||||
}
|
||||
.glow-cyan {
|
||||
box-shadow: 0 0 20px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
/* Status Indicator pulsing */
|
||||
@keyframes pulse-emerald {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
.pulse-emerald {
|
||||
animation: pulse-emerald 2s infinite;
|
||||
background-color: var(--accent-emerald);
|
||||
}
|
||||
|
||||
@keyframes pulse-amber {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0);
|
||||
}
|
||||
}
|
||||
.pulse-amber {
|
||||
animation: pulse-amber 2s infinite;
|
||||
background-color: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* Layout Utilities */
|
||||
.app-container {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
background-image: radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.05), transparent 40%),
|
||||
radial-gradient(circle at 10% 80%, rgba(6, 182, 212, 0.05), transparent 40%);
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 70px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32px;
|
||||
background: rgba(8, 10, 16, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
padding: 32px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Typography and utilities */
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import './globals.css';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Agentic OS HQ — AI Agent Command Center',
|
||||
description: 'Control center and dashboard for monitoring and managing autonomous workflows (Bernard, Gumbo, OpsAgent, Curio).',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ fontFamily: "system-ui", margin: 0, padding: 24 }}>{children}</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,811 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Terminal,
|
||||
User,
|
||||
Cpu,
|
||||
ShieldAlert,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Database,
|
||||
FileText,
|
||||
Sparkles,
|
||||
Send,
|
||||
RefreshCw,
|
||||
Play,
|
||||
GitPullRequest,
|
||||
Layers,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
HeartPulse,
|
||||
LogOut,
|
||||
Maximize2
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Agentic OS HQ</h1>
|
||||
<p>Next.js shell scaffold. Wire this UI to Temporal and Langfuse in later iterations.</p>
|
||||
</main>
|
||||
const [activeTab, setActiveTab] = useState("dashboard");
|
||||
|
||||
// Claw Chat State
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hello! I am Claw, your local agent. I have access to your Kubernetes namespace and Proxmox cluster nodes. How can I help you today?",
|
||||
timestamp: "Just now"
|
||||
}
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isClawTyping, setIsClawTyping] = useState(false);
|
||||
const [clawTerminalLogs, setClawTerminalLogs] = useState([]);
|
||||
|
||||
// OpsAgent State
|
||||
const [pendingApprovals, setPendingApprovals] = useState([
|
||||
{
|
||||
id: "ops-req-102",
|
||||
targetType: "proxmox",
|
||||
targetId: "102",
|
||||
action: "clean_disk",
|
||||
details: "LXC container root filesystem is at 94% disk usage. Recommending apt cache clean and temporary file removal.",
|
||||
timestamp: "5 mins ago",
|
||||
status: "pending"
|
||||
},
|
||||
{
|
||||
id: "ops-req-201",
|
||||
targetType: "kubernetes",
|
||||
targetId: "curio-app-xyz",
|
||||
action: "restart_pod",
|
||||
details: "Pod curio-app-xyz is in CrashLoopBackOff state. Logs indicate: 'Database connection failed: Connection refused by 10.96.20.10:5432'. Recommending pod deletion to trigger k8s reschedule.",
|
||||
timestamp: "10 mins ago",
|
||||
status: "pending"
|
||||
}
|
||||
]);
|
||||
const [opsLogs, setOpsLogs] = useState([
|
||||
"2026-05-29 01:10:00 [OpsAgent] Initialized diagnostic run on K8s cluster...",
|
||||
"2026-05-29 01:10:15 [OpsAgent] Found Pod 'curio-app-xyz' in CrashLoopBackOff. Emitting warning.",
|
||||
"2026-05-29 01:12:02 [OpsAgent] Proxmox VMID 102 root usage checked: 94%. Action required."
|
||||
]);
|
||||
|
||||
// Curio HMS State
|
||||
const [dictationText, setDictationText] = useState(
|
||||
"Patient is a 45-year-old male presenting with severe, crushing chest pain radiating to his left arm. He has a past medical history of hypertension. Admitting to Cardiac ICU."
|
||||
);
|
||||
const [clinicalNote, setClinicalNote] = useState(null);
|
||||
const [billingCodes, setBillingCodes] = useState([]);
|
||||
const [isProcessingClinical, setIsProcessingClinical] = useState(false);
|
||||
|
||||
// Workflow logs state
|
||||
const [workflows, setWorkflows] = useState([
|
||||
{ id: "wf-bernard-001", name: "PRReviewWorkflow", agent: "Bernard", target: "pr-401", status: "completed", result: "Approved with suggestions" },
|
||||
{ id: "wf-gumbo-002", name: "GumboSummarizeWorkflow", agent: "Gumbo", target: "api-spec.md", status: "completed", result: "Summary persisted in PG" },
|
||||
{ id: "wf-ops-003", name: "OpsRemediateWorkflow", agent: "OpsAgent", target: "VMID 102", status: "running", result: "Awaiting approval signal" }
|
||||
]);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const terminalEndRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isClawTyping]);
|
||||
|
||||
useEffect(() => {
|
||||
terminalEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [clawTerminalLogs]);
|
||||
|
||||
// Simulated Claw response logic
|
||||
const handleSendMessage = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
const userText = inputValue;
|
||||
setMessages(prev => [...prev, { role: "user", content: userText, timestamp: "Just now" }]);
|
||||
setInputValue("");
|
||||
setIsClawTyping(true);
|
||||
setClawTerminalLogs(prev => [...prev, `[Claw] Received instruction: "${userText}"`]);
|
||||
|
||||
// Parse commands for visual interactions
|
||||
let responseText = "";
|
||||
let logs = [];
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const cleanInput = userText.toLowerCase();
|
||||
if (cleanInput.includes("pod") || cleanInput.includes("k8s") || cleanInput.includes("kubectl")) {
|
||||
logs = [
|
||||
"Connecting to Kubernetes API server...",
|
||||
"Executing: kubectl get pods -n default",
|
||||
"Found 2 pods in namespace 'default':",
|
||||
" - web-pod-xyz 0/1 CrashLoopBackOff 5 2h",
|
||||
" - db-pod-abc 1/1 Running 0 5d",
|
||||
"Fetching logs for 'web-pod-xyz'...",
|
||||
" Error detected: Database connection refused by 10.96.20.10:5432"
|
||||
];
|
||||
responseText = "I've checked the Kubernetes pods. Pod `web-pod-xyz` is currently failing due to a database connection refusal. You can use the **OpsAgent** tab to approve a pod restart or scale the DB deployment.";
|
||||
} else if (cleanInput.includes("proxmox") || cleanInput.includes("vmid") || cleanInput.includes("disk")) {
|
||||
logs = [
|
||||
"Establishing SSH connection to Proxmox cluster host 192.168.8.225...",
|
||||
"Running: pct status 102",
|
||||
" status: running",
|
||||
"Running: pct exec 102 -- df -h /",
|
||||
" Filesystem Size Used Avail Use% Mounted on",
|
||||
" /dev/loop0 20G 19G 0.2G 94% /",
|
||||
"Disk usage critically high! Proposed action: clean_disk"
|
||||
];
|
||||
responseText = "Proxmox Container ID `102` is running out of disk space (94% used). I have created a pending action under the **OpsAgent Center** to perform automated cleanup. Would you like me to execute it?";
|
||||
} else if (cleanInput.includes("hello") || cleanInput.includes("hi")) {
|
||||
responseText = "Hi there! I am Claw, your local personal assistant. Try asking me to `check k8s pods` or `diagnose proxmox container 102`.";
|
||||
} else {
|
||||
responseText = `I've analyzed your query: "${userText}". I recommend initiating an **OpsRemediateWorkflow** to check the status or scanning your configuration files. Let me know if you would like me to trigger that for you.`;
|
||||
}
|
||||
|
||||
setClawTerminalLogs(prev => [...prev, ...logs, "[Claw] Response formulated successfully."]);
|
||||
setMessages(prev => [...prev, { role: "assistant", content: responseText, timestamp: "Just now" }]);
|
||||
setIsClawTyping(false);
|
||||
};
|
||||
|
||||
// Approval Handlers
|
||||
const handleApprove = async (id, action, target) => {
|
||||
setPendingApprovals(prev => prev.map(item => item.id === id ? { ...item, status: "approved" } : item));
|
||||
setOpsLogs(prev => [...prev, `2026-05-29 01:14:10 [OpsAgent] RECEIVED APPROVAL SIGNAL for request ${id}`]);
|
||||
|
||||
// Simulate execution log
|
||||
let execLogs = [
|
||||
`[OpsAgent] Initiating execution of '${action}' on target '${target}'...`,
|
||||
`[OpsAgent] Sending signal to Temporal workflow...`,
|
||||
];
|
||||
|
||||
if (action === "clean_disk") {
|
||||
execLogs.push(
|
||||
"[OpsAgent] SSH into Proxmox node VMID 102...",
|
||||
"[OpsAgent] running: apt-get clean && rm -rf /tmp/*",
|
||||
"[OpsAgent] Disk space cleared. Root filesystem usage reduced from 94% to 71%."
|
||||
);
|
||||
} else {
|
||||
execLogs.push(
|
||||
`[OpsAgent] Running: kubectl delete pod ${target} -n default`,
|
||||
`[OpsAgent] Pod deleted. K8s controller successfully scheduled a replica.`
|
||||
);
|
||||
}
|
||||
|
||||
// Delay log write to simulate real execution
|
||||
for (const log of execLogs) {
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setOpsLogs(prev => [...prev, log]);
|
||||
}
|
||||
|
||||
setPendingApprovals(prev => prev.filter(item => item.id !== id));
|
||||
setWorkflows(prev => prev.map(wf => wf.target === (action === "clean_disk" ? "VMID 102" : target) ? { ...wf, status: "completed", result: "Remediated successfully" } : wf));
|
||||
};
|
||||
|
||||
const handleReject = (id) => {
|
||||
setPendingApprovals(prev => prev.filter(item => item.id !== id));
|
||||
setOpsLogs(prev => [...prev, `2026-05-29 01:14:30 [OpsAgent] Request ${id} REJECTED by operator. Action aborted.`]);
|
||||
};
|
||||
|
||||
// Clinical note simulation
|
||||
const handleProcessClinical = async () => {
|
||||
setIsProcessingClinical(true);
|
||||
setClinicalNote(null);
|
||||
setBillingCodes([]);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
setClinicalNote({
|
||||
subjective: "Patient is a 45-year-old male presenting with severe, crushing chest pain radiating to his left arm. He reports shortness of breath.",
|
||||
objective: "Alert and oriented. Reports pain scale 8/10.",
|
||||
assessment: "Acute Myocardial Infarction suspected.",
|
||||
plan: "Order EKG, troponin levels. Administer Aspirin 324mg immediately. Admit to Cardiac ICU."
|
||||
});
|
||||
|
||||
setBillingCodes([
|
||||
{ code: "I21.9", desc: "Acute myocardial infarction, unspecified" },
|
||||
{ code: "93000", desc: "Electrocardiogram, tracing and report" },
|
||||
{ code: "99223", desc: "Initial hospital care, high level severity" }
|
||||
]);
|
||||
setIsProcessingClinical(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="sidebar">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "32px" }}>
|
||||
{/* Dashboard Title */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px", padding: "0 8px" }}>
|
||||
<div style={{ width: "32px", height: "32px", borderRadius: "8px", background: "linear-gradient(135deg, var(--accent-violet), var(--accent-cyan))", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Terminal size={18} style={{ color: "#fff" }} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "16px", color: "var(--text-primary)" }}>Agentic OS</h2>
|
||||
<span style={{ fontSize: "11px", color: "var(--text-muted)", letterSpacing: "1px", textTransform: "uppercase" }}>Control HQ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav Items */}
|
||||
<nav style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
<button
|
||||
onClick={() => setActiveTab("dashboard")}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "12px", width: "100%", padding: "12px 16px", border: "none", borderRadius: "8px", cursor: "pointer",
|
||||
background: activeTab === "dashboard" ? "rgba(139, 92, 246, 0.15)" : "transparent",
|
||||
color: activeTab === "dashboard" ? "var(--accent-violet)" : "var(--text-secondary)",
|
||||
fontWeight: activeTab === "dashboard" ? "600" : "500",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<Cpu size={18} />
|
||||
Dashboard Home
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("claw")}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "12px", width: "100%", padding: "12px 16px", border: "none", borderRadius: "8px", cursor: "pointer",
|
||||
background: activeTab === "claw" ? "rgba(6, 182, 212, 0.15)" : "transparent",
|
||||
color: activeTab === "claw" ? "var(--accent-cyan)" : "var(--text-secondary)",
|
||||
fontWeight: activeTab === "claw" ? "600" : "500",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
Claw Assistant
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("ops")}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "12px", width: "100%", padding: "12px 16px", border: "none", borderRadius: "8px", cursor: "pointer",
|
||||
background: activeTab === "ops" ? "rgba(245, 158, 11, 0.15)" : "transparent",
|
||||
color: activeTab === "ops" ? "var(--accent-amber)" : "var(--text-secondary)",
|
||||
fontWeight: activeTab === "ops" ? "600" : "500",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<ShieldAlert size={18} />
|
||||
OpsAgent Center
|
||||
{pendingApprovals.length > 0 && (
|
||||
<span style={{ fontSize: "10px", padding: "2px 6px", borderRadius: "10px", background: "var(--accent-amber)", color: "#000", fontWeight: "700", marginLeft: "auto" }}>
|
||||
{pendingApprovals.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("curio")}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "12px", width: "100%", padding: "12px 16px", border: "none", borderRadius: "8px", cursor: "pointer",
|
||||
background: activeTab === "curio" ? "rgba(16, 185, 129, 0.15)" : "transparent",
|
||||
color: activeTab === "curio" ? "var(--accent-emerald)" : "var(--text-secondary)",
|
||||
fontWeight: activeTab === "curio" ? "600" : "500",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<HeartPulse size={18} />
|
||||
Clinical Workspace
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("logs")}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "12px", width: "100%", padding: "12px 16px", border: "none", borderRadius: "8px", cursor: "pointer",
|
||||
background: activeTab === "logs" ? "rgba(255, 255, 255, 0.05)" : "transparent",
|
||||
color: activeTab === "logs" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
fontWeight: activeTab === "logs" ? "600" : "500",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<Layers size={18} />
|
||||
Workflows & Logs
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", borderTop: "1px solid var(--border-color)", paddingTop: "16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{ width: "8px", height: "8px", borderRadius: "50%" }} className="pulse-emerald"></span>
|
||||
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>Temporal Worker Connected</span>
|
||||
</div>
|
||||
<span style={{ fontSize: "11px", color: "var(--text-muted)" }}>v1.2.0-Production</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="main-content">
|
||||
<header className="header">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<h1 style={{ fontSize: "18px", textTransform: "capitalize" }}>{activeTab.replace("curio", "Clinical Curio").replace("ops", "OpsAgent Hub")}</h1>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-muted)", padding: "2px 8px", background: "rgba(255,255,255,0.05)", borderRadius: "4px" }}>
|
||||
Namespace: ai-agents
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
|
||||
<span style={{ fontSize: "12px", color: "var(--text-primary)" }}>Sysadmin Console</span>
|
||||
<span style={{ fontSize: "10px", color: "var(--accent-cyan)" }}>192.168.8.250</span>
|
||||
</div>
|
||||
<div style={{ width: "36px", height: "36px", borderRadius: "50%", background: "#1f293d", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<User size={18} style={{ color: "var(--text-secondary)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="content-body">
|
||||
{/* TAB 1: DASHBOARD HOME */}
|
||||
{activeTab === "dashboard" && (
|
||||
<div className="fade-in" style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
|
||||
{/* Stat Cards */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: "20px" }}>
|
||||
<div className="glass glass-interactive" style={{ padding: "20px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "12px" }}>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: "14px" }}>Total Active Agents</span>
|
||||
<Cpu style={{ color: "var(--accent-violet)" }} size={20} />
|
||||
</div>
|
||||
<h2 style={{ fontSize: "28px" }}>5 / 8</h2>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-muted)", marginTop: "6px" }}>Active in namespace</div>
|
||||
</div>
|
||||
|
||||
<div className="glass glass-interactive" style={{ padding: "20px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "12px" }}>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: "14px" }}>Temporal Workflows</span>
|
||||
<RefreshCw style={{ color: "var(--accent-cyan)" }} size={20} />
|
||||
</div>
|
||||
<h2 style={{ fontSize: "28px" }}>12 Active</h2>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-emerald)", marginTop: "6px" }}>98.8% success SLA</div>
|
||||
</div>
|
||||
|
||||
<div className="glass glass-interactive" style={{ padding: "20px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "12px" }}>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: "14px" }}>Diagnostic Logs</span>
|
||||
<Database style={{ color: "var(--accent-emerald)" }} size={20} />
|
||||
</div>
|
||||
<h2 style={{ fontSize: "28px" }}>142 MB</h2>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-muted)", marginTop: "6px" }}>Persisted in PostgreSQL</div>
|
||||
</div>
|
||||
|
||||
<div className="glass glass-interactive" style={{ padding: "20px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "12px" }}>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: "14px" }}>Action Approvals</span>
|
||||
<ShieldAlert style={{ color: "var(--accent-amber)" }} size={20} />
|
||||
</div>
|
||||
<h2 style={{ fontSize: "28px", color: "var(--accent-amber)" }}>{pendingApprovals.length} Awaiting</h2>
|
||||
<div style={{ fontSize: "12px", color: "var(--accent-rose)", marginTop: "6px" }}>Require administrator override</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Status Panel */}
|
||||
<div className="glass" style={{ padding: "24px" }}>
|
||||
<h3 style={{ fontSize: "16px", marginBottom: "16px" }}>Core Agent Ecosystem Status</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{[
|
||||
{ name: "Bernard", role: "GitOps Reviewer & Workflow Monitor", status: "Idle", desc: "Awaiting PR push events on Gitea hooks.", type: "system" },
|
||||
{ name: "Gumbo", role: "Documentation Summarizer", status: "Active", desc: "Indexing recent technical wiki docs via MCP filesystem.", type: "assistant" },
|
||||
{ name: "OpsAgent", role: "SRE Diagnostics & Self-Healing", status: "Pending approval", desc: "Awaiting administrator decision for remediation action.", type: "remediator" },
|
||||
{ name: "Claw", role: "Local Assistant CLI (OpenClaw clone)", status: "Active", desc: "Awaiting operator prompt input.", type: "assistant" },
|
||||
{ name: "Curio Billing/Triage", role: "HMS AI Assist", status: "Idle", desc: "Standing by for clinical intake recording inputs.", type: "clinical" },
|
||||
].map((agent, i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", border: "1px solid var(--border-color)", borderRadius: "8px", background: "rgba(255,255,255,0.01)" }}>
|
||||
<div>
|
||||
<h4 style={{ fontSize: "14px", fontWeight: "600" }}>{agent.name}</h4>
|
||||
<p style={{ fontSize: "12px", color: "var(--text-secondary)" }}>{agent.role} — <span style={{ color: "var(--text-muted)" }}>{agent.desc}</span></p>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{ width: "8px", height: "8px", borderRadius: "50%" }} className={agent.status.includes("Pending") ? "pulse-amber" : "pulse-emerald"}></span>
|
||||
<span style={{ fontSize: "12px", fontWeight: "600", color: agent.status.includes("Pending") ? "var(--accent-amber)" : "var(--text-secondary)" }}>{agent.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Trigger Block */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px" }}>
|
||||
<div className="glass" style={{ padding: "20px" }}>
|
||||
<h3 style={{ fontSize: "14px", marginBottom: "12px" }}>Diagnostics & Ops Triggers</h3>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("ops");
|
||||
}}
|
||||
style={{ padding: "10px 16px", border: "none", borderRadius: "6px", background: "linear-gradient(135deg, var(--accent-amber), #d97706)", color: "#000", fontWeight: "600", cursor: "pointer", display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<ShieldAlert size={16} />
|
||||
Go to Approval Center
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("claw");
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "I am ready. Tell me to `check k8s pods` to test system diagnostic logic.", timestamp: "Just now" }]);
|
||||
}}
|
||||
style={{ padding: "10px 16px", border: "1px solid var(--border-color)", borderRadius: "6px", background: "rgba(255,255,255,0.05)", color: "var(--text-primary)", fontWeight: "600", cursor: "pointer" }}
|
||||
>
|
||||
Diagnose Pods
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass" style={{ padding: "20px" }}>
|
||||
<h3 style={{ fontSize: "14px", marginBottom: "12px" }}>Knowledge Intake Trigger</h3>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
alert("Triggered GumboSummarizeWorkflow for api-spec.md");
|
||||
}}
|
||||
style={{ padding: "10px 16px", border: "none", borderRadius: "6px", background: "linear-gradient(135deg, var(--accent-violet), var(--accent-cyan))", color: "#fff", fontWeight: "600", cursor: "pointer", display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
Summarize Documentation (Gumbo)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: CLAW PERSONAL ASSISTANT */}
|
||||
{activeTab === "claw" && (
|
||||
<div className="fade-in" style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: "24px", height: "calc(100vh - 140px)", overflow: "hidden" }}>
|
||||
{/* Chat Panel */}
|
||||
<div className="glass" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||||
{/* Chat Header */}
|
||||
<div style={{ padding: "16px 24px", borderBottom: "1px solid var(--border-color)", display: "flex", alignItems: "center", gap: "12px", background: "rgba(255,255,255,0.01)" }}>
|
||||
<div style={{ width: "10px", height: "10px", borderRadius: "50%" }} className="pulse-emerald"></div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: "15px" }}>Claw Personal Assistant</h3>
|
||||
<span style={{ fontSize: "11px", color: "var(--text-muted)" }}>Runs locally. Accesses file tools & system terminal.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<div style={{ flex: 1, padding: "24px", overflowY: "auto", display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: msg.role === "user" ? "flex-end" : "flex-start",
|
||||
maxWidth: "85%",
|
||||
alignSelf: msg.role === "user" ? "flex-end" : "flex-start"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
background: msg.role === "user" ? "var(--accent-cyan)" : "rgba(255, 255, 255, 0.04)",
|
||||
color: msg.role === "user" ? "#000" : "var(--text-primary)",
|
||||
border: msg.role === "user" ? "none" : "1px solid var(--border-color)"
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<span style={{ fontSize: "10px", color: "var(--text-muted)", marginTop: "4px", padding: "0 4px" }}>{msg.timestamp}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isClawTyping && (
|
||||
<div style={{ alignSelf: "flex-start", display: "flex", alignItems: "center", gap: "4px", padding: "12px 16px", background: "rgba(255, 255, 255, 0.04)", borderRadius: "12px", border: "1px solid var(--border-color)" }}>
|
||||
<span style={{ width: "6px", height: "6px", background: "var(--text-secondary)", borderRadius: "50%", display: "inline-block", animation: "bounce 1.4s infinite ease-in-out both" }}></span>
|
||||
<span style={{ width: "6px", height: "6px", background: "var(--text-secondary)", borderRadius: "50%", display: "inline-block", animation: "bounce 1.4s infinite ease-in-out both 0.2s" }}></span>
|
||||
<span style={{ width: "6px", height: "6px", background: "var(--text-secondary)", borderRadius: "50%", display: "inline-block", animation: "bounce 1.4s infinite ease-in-out both 0.4s" }}></span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Chat Input */}
|
||||
<form onSubmit={handleSendMessage} style={{ padding: "20px", borderTop: "1px solid var(--border-color)", display: "flex", gap: "10px", background: "rgba(8,10,16,0.3)" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Ask Claw: e.g. 'check pods' or 'check disk VMID 102'..."
|
||||
style={{ flex: 1, padding: "12px 16px", border: "1px solid var(--border-color)", borderRadius: "8px", background: "var(--bg-secondary)", color: "var(--text-primary)", outline: "none", fontSize: "14px" }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ padding: "12px 20px", border: "none", borderRadius: "8px", background: "var(--accent-cyan)", color: "#000", fontWeight: "600", cursor: "pointer", display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<Send size={16} />
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Claw Tool & Console Log Panel */}
|
||||
<div className="glass" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", borderLeft: "1px solid var(--border-color)" }}>
|
||||
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border-color)", background: "rgba(255,255,255,0.01)" }}>
|
||||
<h3 style={{ fontSize: "14px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Terminal size={16} style={{ color: "var(--accent-cyan)" }} />
|
||||
Claw Tool Console
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: "16px", background: "#05070a", fontFamily: "var(--font-mono)", fontSize: "11px", color: "#a5f3fc", overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{clawTerminalLogs.length === 0 ? (
|
||||
<div style={{ color: "var(--text-muted)", fontStyle: "italic" }}>Console logs will stream here as Claw calls CLI and API tools...</div>
|
||||
) : (
|
||||
clawTerminalLogs.map((log, i) => (
|
||||
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||
<span style={{ color: "var(--text-muted)" }}>></span> {log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={terminalEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: OPSAGENT HUB */}
|
||||
{activeTab === "ops" && (
|
||||
<div className="fade-in" style={{ display: "grid", gridTemplateColumns: "1fr 400px", gap: "24px", height: "calc(100vh - 140px)" }}>
|
||||
{/* Left Column: Diagnostics and Approvals */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px", overflowY: "auto" }}>
|
||||
{/* Pending Actions */}
|
||||
<div className="glass" style={{ padding: "24px" }}>
|
||||
<h2 style={{ fontSize: "16px", marginBottom: "16px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<ShieldAlert size={20} style={{ color: "var(--accent-amber)" }} />
|
||||
Awaiting System Approvals (Approval Gate)
|
||||
</h2>
|
||||
|
||||
{pendingApprovals.length === 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "40px", gap: "12px", border: "1px dashed var(--border-color)", borderRadius: "8px" }}>
|
||||
<CheckCircle size={32} style={{ color: "var(--accent-emerald)" }} />
|
||||
<h4 style={{ color: "var(--text-primary)" }}>No approvals pending</h4>
|
||||
<p style={{ fontSize: "12px", color: "var(--text-muted)" }}>All systems are running clean and within normal parameters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{pendingApprovals.map((req) => (
|
||||
<div key={req.id} style={{ border: "1px solid var(--border-color)", borderRadius: "8px", background: "rgba(255,255,255,0.02)", overflow: "hidden" }}>
|
||||
<div style={{ padding: "16px", borderBottom: "1px solid var(--border-color)", display: "flex", justifyContent: "space-between", alignItems: "center", background: "rgba(245,158,11,0.03)" }}>
|
||||
<div>
|
||||
<span style={{ fontSize: "11px", fontWeight: "700", textTransform: "uppercase", background: "rgba(245,158,11,0.15)", color: "var(--accent-amber)", padding: "2px 6px", borderRadius: "4px", marginRight: "8px" }}>
|
||||
{req.action.replace("_", " ")}
|
||||
</span>
|
||||
<span style={{ fontSize: "13px", color: "var(--text-primary)", fontWeight: "600" }}>Target: {req.targetType.toUpperCase()} ({req.targetId})</span>
|
||||
</div>
|
||||
<span style={{ fontSize: "11px", color: "var(--text-muted)" }}>{req.timestamp}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px" }}>
|
||||
<p style={{ fontSize: "13px", color: "var(--text-secondary)", lineHeight: "1.5", marginBottom: "16px" }}>{req.details}</p>
|
||||
|
||||
<div style={{ display: "flex", gap: "10px", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => handleReject(req.id)}
|
||||
style={{ padding: "8px 16px", border: "1px solid var(--accent-rose)", borderRadius: "6px", background: "transparent", color: "var(--accent-rose)", fontWeight: "600", cursor: "pointer", display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<XCircle size={14} />
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(req.id, req.action, req.targetId)}
|
||||
style={{ padding: "8px 16px", border: "none", borderRadius: "6px", background: "linear-gradient(135deg, var(--accent-emerald), #059669)", color: "#fff", fontWeight: "600", cursor: "pointer", display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Approve & Execute
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnostics Panel */}
|
||||
<div className="glass" style={{ padding: "24px" }}>
|
||||
<h3 style={{ fontSize: "15px", marginBottom: "12px" }}>OpsAgent Diagnostics Tools</h3>
|
||||
<p style={{ fontSize: "13px", color: "var(--text-secondary)", marginBottom: "16px" }}>Manually run automated checks across your container nodes.</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>
|
||||
<div style={{ padding: "12px", border: "1px solid var(--border-color)", borderRadius: "8px", background: "rgba(255,255,255,0.01)" }}>
|
||||
<span style={{ fontSize: "12px", fontWeight: "600" }}>K8s Diagnostic</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpsLogs(prev => [...prev, `[Manual Trigger] Initiated Kubernetes diagnostics check for namespace 'default'...`]);
|
||||
setTimeout(() => {
|
||||
setOpsLogs(prev => [...prev, `[OpsAgent] web-pod-xyz logs scanned. DB connection failed.`]);
|
||||
}, 1000);
|
||||
}}
|
||||
style={{ width: "100%", padding: "8px", border: "none", borderRadius: "4px", background: "rgba(6, 182, 212, 0.15)", color: "var(--accent-cyan)", fontWeight: "600", fontSize: "11px", cursor: "pointer", marginTop: "8px" }}
|
||||
>
|
||||
Scan Namespace
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "12px", border: "1px solid var(--border-color)", borderRadius: "8px", background: "rgba(255,255,255,0.01)" }}>
|
||||
<span style={{ fontSize: "12px", fontWeight: "600" }}>Proxmox Auditor</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpsLogs(prev => [...prev, `[Manual Trigger] Querying Proxmox host 192.168.8.225 for LXC disk metrics...`]);
|
||||
setTimeout(() => {
|
||||
setOpsLogs(prev => [...prev, `[OpsAgent] VMID 102 checked: 94% storage load. VMID 105 checked: 52% load.`]);
|
||||
}, 1000);
|
||||
}}
|
||||
style={{ width: "100%", padding: "8px", border: "none", borderRadius: "4px", background: "rgba(139, 92, 246, 0.15)", color: "var(--accent-violet)", fontWeight: "600", fontSize: "11px", cursor: "pointer", marginTop: "8px" }}
|
||||
>
|
||||
Audit Disk Usage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Execution Live Terminal */}
|
||||
<div className="glass" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||||
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border-color)", background: "rgba(255,255,255,0.01)" }}>
|
||||
<h3 style={{ fontSize: "14px" }}>OpsAgent Execution Terminal Logs</h3>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: "16px", background: "#05070a", fontFamily: "var(--font-mono)", fontSize: "11px", color: "#a3e635", overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{opsLogs.map((log, i) => (
|
||||
<div key={i} style={{ lineBreak: "anywhere" }}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: CLINICAL WORKSPACE */}
|
||||
{activeTab === "curio" && (
|
||||
<div className="fade-in" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px", height: "calc(100vh - 140px)" }}>
|
||||
{/* Doctor Dictation Section */}
|
||||
<div className="glass" style={{ padding: "24px", display: "flex", flexDirection: "column", gap: "16px", height: "100%", overflowY: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<HeartPulse style={{ color: "var(--accent-emerald)" }} size={24} />
|
||||
<div>
|
||||
<h2 style={{ fontSize: "16px" }}>Clinical Scribe Workspace</h2>
|
||||
<span style={{ fontSize: "11px", color: "var(--text-muted)" }}>Uses ClinicalIntakeWorkflow (Temporal Task Queue)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
<label style={{ fontSize: "12px", color: "var(--text-secondary)", fontWeight: "600" }}>Raw Dictation Input</label>
|
||||
<textarea
|
||||
value={dictationText}
|
||||
onChange={(e) => setDictationText(e.target.value)}
|
||||
style={{ width: "100%", height: "200px", padding: "16px", border: "1px solid var(--border-color)", borderRadius: "8px", background: "var(--bg-secondary)", color: "var(--text-primary)", outline: "none", resize: "none", fontSize: "14px", lineHeight: "1.5" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleProcessClinical}
|
||||
disabled={isProcessingClinical || !dictationText}
|
||||
style={{ width: "100%", padding: "12px", border: "none", borderRadius: "8px", background: "var(--accent-emerald)", color: "#000", fontWeight: "600", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" }}
|
||||
>
|
||||
{isProcessingClinical ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="spin" style={{ animation: "spin 2s linear infinite" }} />
|
||||
Structuring Dictation via LLM...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
Submit to ClinicalIntake Workflow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Structuring Result Output */}
|
||||
<div className="glass" style={{ padding: "24px", display: "flex", flexDirection: "column", gap: "16px", height: "100%", overflowY: "auto" }}>
|
||||
<h3 style={{ fontSize: "15px", borderBottom: "1px solid var(--border-color)", paddingBottom: "12px" }}>Structured Output (SOAP Note)</h3>
|
||||
|
||||
{clinicalNote ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: "12px" }}>
|
||||
<div>
|
||||
<span style={{ fontSize: "11px", fontWeight: "700", color: "var(--accent-emerald)" }}>SUBJECTIVE</span>
|
||||
<p style={{ fontSize: "13px", color: "var(--text-secondary)", marginTop: "4px" }}>{clinicalNote.subjective}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: "11px", fontWeight: "700", color: "var(--accent-emerald)" }}>OBJECTIVE</span>
|
||||
<p style={{ fontSize: "13px", color: "var(--text-secondary)", marginTop: "4px" }}>{clinicalNote.objective}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: "11px", fontWeight: "700", color: "var(--accent-emerald)" }}>ASSESSMENT</span>
|
||||
<p style={{ fontSize: "13px", color: "var(--text-secondary)", marginTop: "4px" }}>{clinicalNote.assessment}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: "11px", fontWeight: "700", color: "var(--accent-emerald)" }}>PLAN</span>
|
||||
<p style={{ fontSize: "13px", color: "var(--text-secondary)", marginTop: "4px" }}>{clinicalNote.plan}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: "1px solid var(--border-color)", paddingTop: "16px" }}>
|
||||
<h4 style={{ fontSize: "12px", color: "var(--text-primary)", marginBottom: "8px" }}>Generated Billing Codes (ICD-10 / CPT)</h4>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "12px" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "var(--text-muted)", borderBottom: "1px solid var(--border-color)" }}>
|
||||
<th style={{ padding: "8px" }}>Code</th>
|
||||
<th style={{ padding: "8px" }}>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{billingCodes.map((bc, idx) => (
|
||||
<tr key={idx} style={{ borderBottom: "1px solid rgba(255,255,255,0.02)" }}>
|
||||
<td style={{ padding: "8px", fontWeight: "600", color: "var(--accent-cyan)" }}>{bc.code}</td>
|
||||
<td style={{ padding: "8px", color: "var(--text-secondary)" }}>{bc.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flex: 1, alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: "13px", fontStyle: "italic", border: "1px dashed var(--border-color)", borderRadius: "8px" }}>
|
||||
No workflow submitted yet. Enter dictation and click submit.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 5: WORKFLOW MONITOR */}
|
||||
{activeTab === "logs" && (
|
||||
<div className="fade-in" style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||
<div className="glass" style={{ padding: "24px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: "16px" }}>Durable Execution Logs</h3>
|
||||
<p style={{ fontSize: "12px", color: "var(--text-muted)" }}>Temporal workflows audit records inside the local postgres cluster.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setWorkflows(prev => [
|
||||
...prev,
|
||||
{ id: `wf-manual-${Math.floor(Math.random()*900+100)}`, name: "GumboSummarizeWorkflow", agent: "Gumbo", target: "wiki-summary.txt", status: "completed", result: "Completed" }
|
||||
]);
|
||||
}}
|
||||
style={{ padding: "8px 14px", border: "none", borderRadius: "6px", background: "rgba(255,255,255,0.05)", color: "var(--text-primary)", fontWeight: "600", cursor: "pointer", fontSize: "12px" }}
|
||||
>
|
||||
Refresh List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "13px" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border-color)", color: "var(--text-muted)", textAlign: "left" }}>
|
||||
<th style={{ padding: "12px 16px" }}>Workflow ID</th>
|
||||
<th style={{ padding: "12px 16px" }}>Workflow Name</th>
|
||||
<th style={{ padding: "12px 16px" }}>Assigned Agent</th>
|
||||
<th style={{ padding: "12px 16px" }}>Target</th>
|
||||
<th style={{ padding: "12px 16px" }}>Status</th>
|
||||
<th style={{ padding: "12px 16px" }}>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workflows.map((wf) => (
|
||||
<tr key={wf.id} style={{ borderBottom: "1px solid var(--border-color)", background: "rgba(255,255,255,0.005)" }}>
|
||||
<td style={{ padding: "12px 16px", fontFamily: "var(--font-mono)", fontSize: "12px" }}>{wf.id}</td>
|
||||
<td style={{ padding: "12px 16px", fontWeight: "600" }}>{wf.name}</td>
|
||||
<td style={{ padding: "12px 16px" }}>{wf.agent}</td>
|
||||
<td style={{ padding: "12px 16px", color: "var(--text-secondary)" }}>{wf.target}</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<span style={{ width: "6px", height: "6px", borderRadius: "50%" }} className={wf.status === "running" ? "pulse-amber" : "pulse-emerald"}></span>
|
||||
<span style={{ textTransform: "capitalize", fontWeight: "600", color: wf.status === "running" ? "var(--accent-amber)" : "var(--text-secondary)" }}>
|
||||
{wf.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px", color: wf.status === "running" ? "var(--text-muted)" : "var(--text-primary)" }}>{wf.result}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,437 @@
|
|||
{
|
||||
"name": "agentic-os-hq-dashboard",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agentic-os-hq-dashboard",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.300.0",
|
||||
"next": "14.2.18",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.18.tgz",
|
||||
"integrity": "sha512-2vWLOUwIPgoqMJKG6dt35fVXVhgM09tw4tK3/Q34GFXDrfiHlG7iS33VA4ggnjWxjiz9KV5xzfsQzJX6vGAekA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.18.tgz",
|
||||
"integrity": "sha512-tOBlDHCjGdyLf0ube/rDUs6VtwNOajaWV+5FV/ajPgrvHeisllEdymY/oDgv2cx561+gJksfMUtqf8crug7sbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.18.tgz",
|
||||
"integrity": "sha512-uJCEjutt5VeJ30jjrHV1VIHCsbMYnEqytQgvREx+DjURd/fmKy15NaVK4aR/u98S1LGTnjq35lRTnRyygglxoA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.18.tgz",
|
||||
"integrity": "sha512-IL6rU8vnBB+BAm6YSWZewc+qvdL1EaA+VhLQ6tlUc0xp+kkdxQrVqAnh8Zek1ccKHlTDFRyAft0e60gteYmQ4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.18.tgz",
|
||||
"integrity": "sha512-RCaENbIZqKKqTlL8KNd+AZV/yAdCsovblOpYFp0OJ7ZxgLNbV5w23CUU1G5On+0fgafrsGcW+GdMKdFjaRwyYA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.18.tgz",
|
||||
"integrity": "sha512-3kmv8DlyhPRCEBM1Vavn8NjyXtMeQ49ID0Olr/Sut7pgzaQTo4h01S7Z8YNE0VtbowyuAL26ibcz0ka6xCTH5g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.18.tgz",
|
||||
"integrity": "sha512-mliTfa8seVSpTbVEcKEXGjC18+TDII8ykW4a36au97spm9XMPqQTpdGPNBJ9RySSFw9/hLuaCMByluQIAnkzlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.18.tgz",
|
||||
"integrity": "sha512-J5g0UFPbAjKYmqS3Cy7l2fetFmWMY9Oao32eUsBPYohts26BdrMUyfCJnZFQkX9npYaHNDOWqZ6uV9hSDPw9NA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.18.tgz",
|
||||
"integrity": "sha512-Ynxuk4ZgIpdcN7d16ivJdjsDG1+3hTvK24Pp8DiDmIa2+A4CfhJSEHHVndCHok6rnLUzAZD+/UOKESQgTsAZGg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.18.tgz",
|
||||
"integrity": "sha512-dtRGMhiU9TN5nyhwzce+7c/4CCeykYS+ipY/4mIrGzJ71+7zNo55ZxCB7cAVuNqdwtYniFNR2c9OFQ6UdFIMcg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
|
||||
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.300.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.300.0.tgz",
|
||||
"integrity": "sha512-rQxUUCmWAvNLoAsMZ5j04b2+OJv6UuNLYMY7VK0eVlm4aTwUEjEEHc09/DipkNIlhXUSDn2xoyIzVT0uh7dRsg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "14.2.18",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-14.2.18.tgz",
|
||||
"integrity": "sha512-H9qbjDuGivUDEnK6wa+p2XKO+iMzgVgyr9Zp/4Iv29lKa+DYaxJGjOeEA+5VOvJh/M7HLiskehInSa0cWxVXUw==",
|
||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "14.2.18",
|
||||
"@swc/helpers": "0.5.5",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"next": "dist/bin/next"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "14.2.18",
|
||||
"@next/swc-darwin-x64": "14.2.18",
|
||||
"@next/swc-linux-arm64-gnu": "14.2.18",
|
||||
"@next/swc-linux-arm64-musl": "14.2.18",
|
||||
"@next/swc-linux-x64-gnu": "14.2.18",
|
||||
"@next/swc-linux-x64-musl": "14.2.18",
|
||||
"@next/swc-win32-arm64-msvc": "14.2.18",
|
||||
"@next/swc-win32-ia32-msvc": "14.2.18",
|
||||
"@next/swc-win32-x64-msvc": "14.2.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sass": "^1.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@playwright/test": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
||||
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"client-only": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"dependencies": {
|
||||
"next": "14.2.18",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"lucide-react": "^0.300.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: hq-dashboard
|
||||
namespace: ai-agents
|
||||
labels:
|
||||
app: hq-dashboard
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: hq-dashboard
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: hq-dashboard
|
||||
spec:
|
||||
serviceAccountName: default
|
||||
initContainers:
|
||||
- name: git-clone
|
||||
image: alpine/git
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- "git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace && cp -r /workspace/interface/hq-dashboard/* /app/"
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
containers:
|
||||
- name: hq-dashboard
|
||||
image: node:18-alpine
|
||||
command: ["sh", "-c", "cd /app && npm install && npm run build && npm start"]
|
||||
volumeMounts:
|
||||
- name: app-code
|
||||
mountPath: /app
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
- name: app-code
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: hq-dashboard
|
||||
namespace: ai-agents
|
||||
spec:
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: hq-dashboard
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: hq-dashboard-ingress
|
||||
namespace: ai-agents
|
||||
annotations:
|
||||
k8s.apisix.apache.org/enable-websocket: "true"
|
||||
spec:
|
||||
ingressClassName: apisix
|
||||
rules:
|
||||
- host: hq.applaude.net
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: hq-dashboard
|
||||
port:
|
||||
number: 80
|
||||
path: /
|
||||
pathType: Prefix
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "whatsapp-gateway",
|
||||
"version": "1.0.0",
|
||||
"description": "Local HTTP WhatsApp Web gateway for autonomous alerting",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"whatsapp-web.js": "^1.23.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
const express = require('express');
|
||||
const { Client, LocalAuth } = require('whatsapp-web.js');
|
||||
const qrcode = require('qrcode-terminal');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5001;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize WhatsApp Web Client with LocalAuth to persist session
|
||||
const client = new Client({
|
||||
authStrategy: new LocalAuth({
|
||||
dataPath: './.wwebjs_auth'
|
||||
}),
|
||||
puppeteer: {
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
}
|
||||
});
|
||||
|
||||
let isReady = false;
|
||||
|
||||
// Event: QR Code generation
|
||||
client.on('qr', (qr) => {
|
||||
console.log('\n==================================================================');
|
||||
console.log('SCAN THIS QR CODE WITH YOUR WHATSAPP APP TO LOG IN:');
|
||||
console.log('==================================================================\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
console.log('\n==================================================================');
|
||||
});
|
||||
|
||||
// Event: Successfully logged in & authenticated
|
||||
client.on('ready', () => {
|
||||
console.log('\n[WhatsApp Bot] Client is ready and authenticated!');
|
||||
isReady = true;
|
||||
});
|
||||
|
||||
client.on('auth_failure', (msg) => {
|
||||
console.error('[WhatsApp Bot] Authentication failure:', msg);
|
||||
});
|
||||
|
||||
client.on('disconnected', (reason) => {
|
||||
console.log('[WhatsApp Bot] Client was logged out:', reason);
|
||||
isReady = false;
|
||||
});
|
||||
|
||||
// HTTP REST Endpoint to send messages
|
||||
app.post('/send-message', async (req, res) => {
|
||||
if (!isReady) {
|
||||
return res.status(503).json({ success: false, error: 'WhatsApp Web client is not ready. Please scan the QR code in the server terminal.' });
|
||||
}
|
||||
|
||||
const { to, message } = req.body;
|
||||
if (!to || !message) {
|
||||
return res.status(400).json({ success: false, error: "Missing 'to' or 'message' fields in JSON payload." });
|
||||
}
|
||||
|
||||
try {
|
||||
let targetJid = to;
|
||||
|
||||
// 1. Check if 'to' is a WhatsApp group invite link or invite code
|
||||
if (to.includes('chat.whatsapp.com') || /^[A-Za-z0-9]{20,24}$/.test(to)) {
|
||||
let inviteCode = to;
|
||||
if (to.includes('chat.whatsapp.com/')) {
|
||||
inviteCode = to.split('chat.whatsapp.com/')[1].split('?')[0].trim();
|
||||
}
|
||||
console.log(`[WhatsApp Bot] Attempting to join group via invite code: ${inviteCode}`);
|
||||
try {
|
||||
const groupChatId = await client.acceptInvite(inviteCode);
|
||||
targetJid = groupChatId;
|
||||
console.log(`[WhatsApp Bot] Successfully joined/resolved group. JID: ${targetJid}`);
|
||||
} catch (err) {
|
||||
console.warn(`[WhatsApp Bot] Accept invite failed (bot might already be in the group):`, err.message);
|
||||
// Fallback: If joining failed, attempt to find by code if we can't join
|
||||
return res.status(400).json({ success: false, error: `Failed to join group via invite code: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Format phone number to standard JID format if it is a plain phone number
|
||||
if (!targetJid.includes('@')) {
|
||||
// Strip any prefix, plus, space, parentheses, or dashes
|
||||
let cleanPhone = targetJid.replace('whatsapp:', '').replace(/[+\s()-]/g, '').trim();
|
||||
targetJid = `${cleanPhone}@c.us`;
|
||||
}
|
||||
|
||||
console.log(`[WhatsApp Bot] Sending message to target JID: ${targetJid}`);
|
||||
const response = await client.sendMessage(targetJid, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
messageId: response.id._serialized,
|
||||
recipient: targetJid
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[WhatsApp Bot] Failed to send message:`, error);
|
||||
res.status(500).json({ success: false, error: error.message || error });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/status', (req, res) => {
|
||||
res.json({
|
||||
service: 'whatsapp-gateway',
|
||||
ready: isReady,
|
||||
authenticated: client.info ? true : false
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[WhatsApp Bot Gateway] REST API running at http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
console.log('[WhatsApp Bot] Initializing client...');
|
||||
client.initialize();
|
||||
Loading…
Reference in New Issue