API Clients + structured JSON results: app-level tokens for Synap/WSIT integration
- New api_clients + api_client_scopes tables; tokens scoped per-instance
- Admin UI tab at /admin for token create/rotate/revoke/delete with one-time reveal
- Dual-auth dependency (user session OR Bearer app token) on trigger + runs endpoints
- /api/instances/{id}/trigger pre-creates a run and returns run_id + cached last_result instantly
- New GET /api/runs/{id} for polling
- Generic trigger path for sub-agent instances (weather, calendar, etc.)
- runs.result column for structured JSON alongside markdown output
- agent_catalog.result_schema describes each agent's result shape
- Weather, daily-briefing, project-monitor retrofitted to emit structured results
- log_run: env INSTANCE_ID/RUN_ID only used when target matches, so nested sub-agents don't clobber parent runs
- Wiki docs: API Clients & Token Scoping + Calling Agents From Your Apps
This commit is contained in:
+462
-29
@@ -1,4 +1,4 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, Response, Cookie
|
||||
from fastapi import FastAPI, Depends, HTTPException, Response, Cookie, Header
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -13,8 +13,8 @@ import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from database import get_db, init_db
|
||||
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog
|
||||
from database import get_db, init_db, SessionLocal
|
||||
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog, APIClient, APIClientScope, APIClientCall
|
||||
|
||||
app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
|
||||
|
||||
@@ -70,6 +70,94 @@ def require_admin(session: Optional[str] = Cookie(None)) -> dict:
|
||||
return user
|
||||
|
||||
|
||||
# --- API Client auth (for external apps like Synap) ---
|
||||
|
||||
def _hash_api_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _load_api_client(authorization: Optional[str], db: Session) -> Optional[APIClient]:
|
||||
"""Resolve an API client from a Bearer token. Returns None if absent/invalid/revoked."""
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
return None
|
||||
token = authorization[7:].strip()
|
||||
if not token:
|
||||
return None
|
||||
th = _hash_api_token(token)
|
||||
client = db.query(APIClient).filter(
|
||||
APIClient.token_hash == th,
|
||||
APIClient.revoked_at.is_(None),
|
||||
).first()
|
||||
if client:
|
||||
client.last_used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return client
|
||||
|
||||
|
||||
def require_user_or_api(
|
||||
session: Optional[str] = Cookie(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Accept either a logged-in user session OR a Bearer API token.
|
||||
Returns a dict describing the caller:
|
||||
{"kind": "user", "user_id": int, "username": str, "role": str, "allowed_instance_ids": None}
|
||||
{"kind": "api", "api_client_id": int, "api_client_name": str, "allowed_instance_ids": set[int]}
|
||||
"""
|
||||
u = get_current_user(session)
|
||||
if u:
|
||||
return {
|
||||
"kind": "user",
|
||||
"user_id": u["user_id"],
|
||||
"username": u["username"],
|
||||
"role": u["role"],
|
||||
"allowed_instance_ids": None, # user auth is scoped by instance ownership
|
||||
}
|
||||
client = _load_api_client(authorization, db)
|
||||
if client:
|
||||
allowed = {s.instance_id for s in client.scopes}
|
||||
return {
|
||||
"kind": "api",
|
||||
"api_client_id": client.id,
|
||||
"api_client_name": client.name,
|
||||
"allowed_instance_ids": allowed,
|
||||
}
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
def caller_can_access_instance(caller: dict, inst: AgentInstance) -> bool:
|
||||
"""Authorization check: does this caller have rights to read/trigger this instance?"""
|
||||
if caller["kind"] == "user":
|
||||
# Admins can access any instance; users only their own
|
||||
return caller["role"] == "admin" or inst.user_id == caller["user_id"]
|
||||
# API client: must have instance in scope
|
||||
return inst.id in caller["allowed_instance_ids"]
|
||||
|
||||
|
||||
def caller_label(caller: dict) -> str:
|
||||
"""Short identifier for logging: 'user:eric' or 'api_client:synap'."""
|
||||
if caller["kind"] == "user":
|
||||
return f"user:{caller['username']}"
|
||||
return f"api_client:{caller['api_client_name']}"
|
||||
|
||||
|
||||
def log_api_client_call(db: Session, caller: dict, endpoint: str, instance_id: Optional[int], status_code: int):
|
||||
"""Record an audit entry for API client calls (no-op for user sessions)."""
|
||||
if caller["kind"] != "api":
|
||||
return
|
||||
try:
|
||||
entry = APIClientCall(
|
||||
api_client_id=caller["api_client_id"],
|
||||
instance_id=instance_id,
|
||||
endpoint=endpoint,
|
||||
status_code=status_code,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -96,6 +184,8 @@ class RunCreate(BaseModel):
|
||||
output: str = ""
|
||||
error: str = ""
|
||||
metadata: dict = {}
|
||||
result: Optional[dict] = None # structured data for API consumers
|
||||
run_id: Optional[int] = None # if set, update this run instead of creating a new one
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
@@ -126,6 +216,16 @@ class LLMProviderUpdate(BaseModel):
|
||||
default_model: Optional[str] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
class APIClientCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
instance_ids: list[int] = [] # which instances this client can trigger/read
|
||||
|
||||
class APIClientUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
instance_ids: Optional[list[int]] = None # replaces existing scopes if provided
|
||||
|
||||
|
||||
# --- Auth Routes ---
|
||||
|
||||
@@ -329,6 +429,7 @@ def list_catalog(user: dict = Depends(require_auth), db: Session = Depends(get_d
|
||||
"default_config": e.default_config or {},
|
||||
"supports_schedule": e.supports_schedule, "is_sub_agent": e.is_sub_agent,
|
||||
"requires_llm": e.requires_llm,
|
||||
"result_schema": e.result_schema or {},
|
||||
"enabled": e.id in user_instance_ids,
|
||||
} for e in entries]
|
||||
|
||||
@@ -343,6 +444,8 @@ def get_catalog_entry(catalog_id: str, user: dict = Depends(require_auth), db: S
|
||||
"category": entry.category, "config_schema": entry.config_schema or {},
|
||||
"default_config": entry.default_config or {},
|
||||
"supports_schedule": entry.supports_schedule, "is_sub_agent": entry.is_sub_agent,
|
||||
"requires_llm": entry.requires_llm,
|
||||
"result_schema": entry.result_schema or {},
|
||||
}
|
||||
|
||||
|
||||
@@ -462,54 +565,108 @@ def delete_instance(instance_id: int, user: dict = Depends(require_auth), db: Se
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
SUB_AGENT_SCRIPTS = {
|
||||
"weather": "weather_agent.py",
|
||||
"calendar": "calendar_agent.py",
|
||||
"reminders": "reminders_agent.py",
|
||||
"notes": "notes_agent.py",
|
||||
"reading-list": "reading_list_agent.py",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/instances/{instance_id}/trigger")
|
||||
def trigger_instance(instance_id: int, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""Trigger a manual run of an agent instance. Runs async via subprocess."""
|
||||
inst = db.query(AgentInstance).filter(
|
||||
AgentInstance.id == instance_id, AgentInstance.user_id == user["user_id"]
|
||||
).first()
|
||||
def trigger_instance(
|
||||
instance_id: int,
|
||||
caller: dict = Depends(require_user_or_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Trigger a manual run of an agent instance.
|
||||
Accepts either a user session cookie or a Bearer app token (scoped to this instance).
|
||||
Returns immediately with a run_id to poll + the last successful run's cached result."""
|
||||
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
|
||||
if not inst:
|
||||
raise HTTPException(status_code=404)
|
||||
if not caller_can_access_instance(caller, inst):
|
||||
log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 403)
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this instance")
|
||||
|
||||
# Determine which script to run based on catalog type and user
|
||||
catalog_id = inst.catalog_id
|
||||
u = db.query(User).filter(User.id == user["user_id"]).first()
|
||||
u = db.query(User).filter(User.id == inst.user_id).first()
|
||||
|
||||
# Look up last successful run — we return its cached result immediately
|
||||
last = db.query(Run).filter(
|
||||
Run.instance_id == instance_id,
|
||||
Run.status == "success",
|
||||
).order_by(Run.started_at.desc()).first()
|
||||
last_result = last.result if last else None
|
||||
last_run_at = last.started_at.isoformat() if last and last.started_at else None
|
||||
|
||||
# Create a placeholder run row so the caller gets an id to poll
|
||||
new_run = Run(
|
||||
instance_id=instance_id,
|
||||
user_id=inst.user_id,
|
||||
status="queued",
|
||||
triggered_by=caller_label(caller),
|
||||
)
|
||||
db.add(new_run)
|
||||
db.commit()
|
||||
db.refresh(new_run)
|
||||
|
||||
import subprocess
|
||||
agent_dir = "/app/agents"
|
||||
env = {**dict(os.environ), "PYTHONPATH": agent_dir}
|
||||
env = {
|
||||
**dict(os.environ),
|
||||
"PYTHONPATH": agent_dir,
|
||||
"RUN_ID": str(new_run.id),
|
||||
"INSTANCE_ID": str(instance_id),
|
||||
}
|
||||
|
||||
cmd = None
|
||||
if catalog_id == "daily-briefing":
|
||||
script_map = {"eric": "eric_briefing.py", "angela": "angela_briefing.py"}
|
||||
script = script_map.get(u.username, None)
|
||||
script = script_map.get(u.username)
|
||||
env_key = f"{u.username.upper().replace('.', '_')}_INSTANCE_ID"
|
||||
env[env_key] = str(instance_id)
|
||||
if script:
|
||||
cmd = ["python3", f"{agent_dir}/{script}"]
|
||||
else:
|
||||
# Generic: run the engine directly with instance config
|
||||
cmd = ["python3", "-c",
|
||||
f"import sys; sys.path.insert(0, '{agent_dir}'); "
|
||||
f"from daily_briefing import run; "
|
||||
f"run({{'person': '{u.display_name}', 'agent_id': '{catalog_id}', "
|
||||
f"'instance_id': {instance_id}, 'wiki_parent_doc_id': '', 'location': {{}}}})"]
|
||||
subprocess.Popen(cmd, env=env, cwd=agent_dir)
|
||||
return {"status": "triggered", "message": f"Running {catalog_id} for {u.display_name}"}
|
||||
|
||||
if catalog_id == "project-monitor":
|
||||
# Write config to a temp file to avoid shell escaping issues
|
||||
elif catalog_id == "project-monitor":
|
||||
config_path = f"/tmp/pm_config_{instance_id}.json"
|
||||
with open(config_path, "w") as f:
|
||||
json.dump({"config": inst.config or {}, "user_id": user["user_id"], "instance_id": instance_id}, f)
|
||||
json.dump({"config": inst.config or {}, "user_id": inst.user_id, "instance_id": instance_id}, f)
|
||||
cmd = ["python3", "-c",
|
||||
f"import sys, json; sys.path.insert(0, '{agent_dir}'); "
|
||||
f"d = json.load(open('{config_path}')); "
|
||||
f"from project_monitor import run; "
|
||||
f"run(d['config'], user_id=d['user_id'], instance_id=d['instance_id'])"]
|
||||
subprocess.Popen(cmd, env=env, cwd=agent_dir)
|
||||
return {"status": "triggered", "message": f"Running project monitor: {inst.name}"}
|
||||
elif catalog_id in SUB_AGENT_SCRIPTS:
|
||||
cmd = ["python3", f"{agent_dir}/{SUB_AGENT_SCRIPTS[catalog_id]}", "--from-api"]
|
||||
|
||||
return {"status": "error", "message": f"Manual trigger not yet supported for {catalog_id}"}
|
||||
if not cmd:
|
||||
new_run.status = "failed"
|
||||
new_run.error = f"No runner configured for catalog_id={catalog_id}"
|
||||
new_run.finished_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 400)
|
||||
raise HTTPException(status_code=400, detail=f"Manual trigger not supported for {catalog_id}")
|
||||
|
||||
subprocess.Popen(cmd, env=env, cwd=agent_dir)
|
||||
log_api_client_call(db, caller, f"POST /api/instances/{instance_id}/trigger", instance_id, 200)
|
||||
|
||||
return {
|
||||
"run_id": new_run.id,
|
||||
"status": "queued",
|
||||
"instance_id": instance_id,
|
||||
"catalog_id": catalog_id,
|
||||
"triggered_by": new_run.triggered_by,
|
||||
"last_result": last_result,
|
||||
"last_run_at": last_run_at,
|
||||
}
|
||||
|
||||
|
||||
# --- Internal endpoints (no auth, for agent scripts) ---
|
||||
@@ -533,9 +690,31 @@ def get_instance_config(instance_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@app.post("/api/instances/{instance_id}/runs")
|
||||
def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
|
||||
"""Internal endpoint: agents POST here when they start and finish.
|
||||
If run.run_id is set, update that existing row (used when /trigger pre-created a run).
|
||||
Otherwise create a new row (used by cron-launched agents that weren't pre-triggered)."""
|
||||
inst = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first()
|
||||
if not inst:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
if run.run_id:
|
||||
existing = db.query(Run).filter(Run.id == run.run_id, Run.instance_id == instance_id).first()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Run {run.run_id} not found for this instance")
|
||||
existing.status = run.status
|
||||
if run.output:
|
||||
existing.output = run.output
|
||||
if run.error:
|
||||
existing.error = run.error
|
||||
if run.metadata:
|
||||
existing.metadata_ = run.metadata
|
||||
if run.result is not None:
|
||||
existing.result = run.result
|
||||
if run.status in ("success", "failed"):
|
||||
existing.finished_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"id": existing.id, "status": existing.status}
|
||||
|
||||
new_run = Run(
|
||||
instance_id=instance_id,
|
||||
user_id=inst.user_id,
|
||||
@@ -543,6 +722,7 @@ def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
|
||||
output=run.output,
|
||||
error=run.error,
|
||||
metadata_=run.metadata,
|
||||
result=run.result,
|
||||
)
|
||||
if run.status in ("success", "failed"):
|
||||
new_run.finished_at = datetime.now(timezone.utc)
|
||||
@@ -551,17 +731,46 @@ def create_run(instance_id: int, run: RunCreate, db: Session = Depends(get_db)):
|
||||
return {"id": new_run.id, "status": new_run.status}
|
||||
|
||||
|
||||
# --- Runs (user-scoped) ---
|
||||
def _serialize_run(r: Run) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"instance_id": r.instance_id,
|
||||
"status": r.status,
|
||||
"result": r.result,
|
||||
"output": r.output,
|
||||
"error": r.error,
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
|
||||
"triggered_by": getattr(r, "triggered_by", "") or "",
|
||||
"metadata": r.metadata_,
|
||||
}
|
||||
|
||||
|
||||
# --- Runs (user or api-client scoped) ---
|
||||
|
||||
@app.get("/api/runs")
|
||||
def list_runs(limit: int = 50, user: dict = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
runs = db.query(Run).filter(Run.user_id == user["user_id"]).order_by(Run.started_at.desc()).limit(limit).all()
|
||||
return [{
|
||||
"id": r.id, "instance_id": r.instance_id,
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
|
||||
"status": r.status, "output": r.output, "error": r.error, "metadata": r.metadata_,
|
||||
} for r in runs]
|
||||
return [_serialize_run(r) for r in runs]
|
||||
|
||||
|
||||
@app.get("/api/runs/{run_id}")
|
||||
def get_run(
|
||||
run_id: int,
|
||||
caller: dict = Depends(require_user_or_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Fetch a single run. Used by callers polling a triggered run until it finishes.
|
||||
Accepts user session or Bearer app token (scoped to the run's instance)."""
|
||||
run = db.query(Run).filter(Run.id == run_id).first()
|
||||
if not run:
|
||||
raise HTTPException(status_code=404)
|
||||
inst = db.query(AgentInstance).filter(AgentInstance.id == run.instance_id).first()
|
||||
if not inst or not caller_can_access_instance(caller, inst):
|
||||
log_api_client_call(db, caller, f"GET /api/runs/{run_id}", run.instance_id, 403)
|
||||
raise HTTPException(status_code=403, detail="Not authorized for this run")
|
||||
log_api_client_call(db, caller, f"GET /api/runs/{run_id}", run.instance_id, 200)
|
||||
return _serialize_run(run)
|
||||
|
||||
|
||||
# --- Admin: Users ---
|
||||
@@ -671,6 +880,146 @@ def admin_delete_provider(provider_id: int, admin: dict = Depends(require_admin)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# --- Admin: API Clients (external app tokens) ---
|
||||
|
||||
def _serialize_api_client(client: APIClient, include_token: Optional[str] = None) -> dict:
|
||||
return {
|
||||
"id": client.id,
|
||||
"name": client.name,
|
||||
"description": client.description or "",
|
||||
"token_prefix": client.token_prefix or "",
|
||||
"token": include_token, # only set once, on creation
|
||||
"instance_ids": [s.instance_id for s in client.scopes],
|
||||
"created_at": client.created_at.isoformat() if client.created_at else None,
|
||||
"last_used_at": client.last_used_at.isoformat() if client.last_used_at else None,
|
||||
"revoked_at": client.revoked_at.isoformat() if client.revoked_at else None,
|
||||
"revoked": client.revoked_at is not None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/instances")
|
||||
def admin_list_all_instances(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
"""Admin-only: list every instance across all users. Used by the API Clients UI to
|
||||
pick which instances a token can access."""
|
||||
rows = db.query(AgentInstance, User).join(User, AgentInstance.user_id == User.id)\
|
||||
.order_by(User.username, AgentInstance.catalog_id).all()
|
||||
return [{
|
||||
"id": inst.id,
|
||||
"name": inst.name,
|
||||
"catalog_id": inst.catalog_id,
|
||||
"status": inst.status,
|
||||
"user_id": u.id,
|
||||
"username": u.username,
|
||||
"display_name": u.display_name,
|
||||
} for inst, u in rows]
|
||||
|
||||
|
||||
@app.get("/api/admin/api-clients")
|
||||
def admin_list_api_clients(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
clients = db.query(APIClient).order_by(APIClient.created_at.desc()).all()
|
||||
return [_serialize_api_client(c) for c in clients]
|
||||
|
||||
|
||||
@app.post("/api/admin/api-clients")
|
||||
def admin_create_api_client(data: APIClientCreate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
if db.query(APIClient).filter(APIClient.name == data.name).first():
|
||||
raise HTTPException(status_code=409, detail="A client with that name already exists")
|
||||
# Verify every instance_id exists
|
||||
for iid in data.instance_ids:
|
||||
if not db.query(AgentInstance).filter(AgentInstance.id == iid).first():
|
||||
raise HTTPException(status_code=400, detail=f"Instance {iid} does not exist")
|
||||
# Generate a token: 'acc_' + 40 hex chars (no ambiguity with OAuth/other schemes)
|
||||
plaintext = "acc_" + secrets.token_hex(20)
|
||||
client = APIClient(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
token_hash=_hash_api_token(plaintext),
|
||||
token_prefix=plaintext[:12], # "acc_" + 8 hex
|
||||
)
|
||||
db.add(client)
|
||||
db.flush()
|
||||
for iid in data.instance_ids:
|
||||
db.add(APIClientScope(api_client_id=client.id, instance_id=iid))
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
return _serialize_api_client(client, include_token=plaintext)
|
||||
|
||||
|
||||
@app.put("/api/admin/api-clients/{client_id}")
|
||||
def admin_update_api_client(client_id: int, update: APIClientUpdate, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
client = db.query(APIClient).filter(APIClient.id == client_id).first()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404)
|
||||
if update.name is not None:
|
||||
dup = db.query(APIClient).filter(APIClient.name == update.name, APIClient.id != client_id).first()
|
||||
if dup:
|
||||
raise HTTPException(status_code=409, detail="A client with that name already exists")
|
||||
client.name = update.name
|
||||
if update.description is not None:
|
||||
client.description = update.description
|
||||
if update.instance_ids is not None:
|
||||
for iid in update.instance_ids:
|
||||
if not db.query(AgentInstance).filter(AgentInstance.id == iid).first():
|
||||
raise HTTPException(status_code=400, detail=f"Instance {iid} does not exist")
|
||||
# Replace scope set
|
||||
db.query(APIClientScope).filter(APIClientScope.api_client_id == client_id).delete()
|
||||
for iid in update.instance_ids:
|
||||
db.add(APIClientScope(api_client_id=client_id, instance_id=iid))
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
return _serialize_api_client(client)
|
||||
|
||||
|
||||
@app.post("/api/admin/api-clients/{client_id}/revoke")
|
||||
def admin_revoke_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
client = db.query(APIClient).filter(APIClient.id == client_id).first()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404)
|
||||
client.revoked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"id": client.id, "status": "revoked"}
|
||||
|
||||
|
||||
@app.post("/api/admin/api-clients/{client_id}/rotate")
|
||||
def admin_rotate_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
"""Issue a new token for an existing client (invalidates the old one)."""
|
||||
client = db.query(APIClient).filter(APIClient.id == client_id).first()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404)
|
||||
plaintext = "acc_" + secrets.token_hex(20)
|
||||
client.token_hash = _hash_api_token(plaintext)
|
||||
client.token_prefix = plaintext[:12]
|
||||
client.revoked_at = None # un-revoke if it was
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
return _serialize_api_client(client, include_token=plaintext)
|
||||
|
||||
|
||||
@app.delete("/api/admin/api-clients/{client_id}")
|
||||
def admin_delete_api_client(client_id: int, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
client = db.query(APIClient).filter(APIClient.id == client_id).first()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404)
|
||||
db.delete(client)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@app.get("/api/admin/api-clients/{client_id}/calls")
|
||||
def admin_api_client_calls(client_id: int, limit: int = 50, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
if not db.query(APIClient).filter(APIClient.id == client_id).first():
|
||||
raise HTTPException(status_code=404)
|
||||
calls = db.query(APIClientCall).filter(APIClientCall.api_client_id == client_id)\
|
||||
.order_by(APIClientCall.called_at.desc()).limit(limit).all()
|
||||
return [{
|
||||
"id": c.id,
|
||||
"endpoint": c.endpoint,
|
||||
"instance_id": c.instance_id,
|
||||
"status_code": c.status_code,
|
||||
"called_at": c.called_at.isoformat() if c.called_at else None,
|
||||
} for c in calls]
|
||||
|
||||
|
||||
# --- Admin: Catalog Management ---
|
||||
|
||||
class CatalogCreate(BaseModel):
|
||||
@@ -682,6 +1031,8 @@ class CatalogCreate(BaseModel):
|
||||
default_config: dict = {}
|
||||
supports_schedule: bool = True
|
||||
is_sub_agent: bool = False
|
||||
requires_llm: bool = False
|
||||
result_schema: dict = {}
|
||||
|
||||
class CatalogUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
@@ -691,6 +1042,8 @@ class CatalogUpdate(BaseModel):
|
||||
default_config: Optional[dict] = None
|
||||
supports_schedule: Optional[bool] = None
|
||||
is_sub_agent: Optional[bool] = None
|
||||
requires_llm: Optional[bool] = None
|
||||
result_schema: Optional[dict] = None
|
||||
|
||||
|
||||
@app.post("/api/admin/catalog")
|
||||
@@ -1142,8 +1495,88 @@ def root(session: Optional[str] = Cookie(None)):
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
# --- Result schemas (what each agent's structured result looks like) ---
|
||||
|
||||
RESULT_SCHEMAS = {
|
||||
"weather": {
|
||||
"description": "Current conditions + 7-day forecast for a configured location.",
|
||||
"shape": {
|
||||
"location": {
|
||||
"name": "string", "state": "string", "country": "string",
|
||||
"lat": "number", "lon": "number", "label": "string",
|
||||
},
|
||||
"current": {
|
||||
"condition": "string", "weather_code": "int",
|
||||
"temperature_f": "int", "feels_like_f": "int",
|
||||
"wind_mph": "int", "humidity_pct": "int",
|
||||
},
|
||||
"forecast": "array of 7 day objects: {date, weekday, condition, weather_code, temp_high_f, temp_low_f, precip_in, wind_max_mph, sunrise, sunset}",
|
||||
"fetched_at": "ISO datetime string (Mountain Time)",
|
||||
},
|
||||
},
|
||||
"calendar": {
|
||||
"description": "Upcoming calendar events across configured sources. Structured result not yet populated — markdown only.",
|
||||
"shape": {"note": "result_schema not yet implemented for this agent"},
|
||||
},
|
||||
"reminders": {
|
||||
"description": "Pending/overdue reminders from configured CalDAV sources. Structured result not yet populated — markdown only.",
|
||||
"shape": {"note": "result_schema not yet implemented for this agent"},
|
||||
},
|
||||
"notes": {
|
||||
"description": "Recent Apple Notes via Mac bridge. Structured result not yet populated — markdown only.",
|
||||
"shape": {"note": "result_schema not yet implemented for this agent"},
|
||||
},
|
||||
"reading-list": {
|
||||
"description": "Safari Reading List items via Mac bridge. Structured result not yet populated — markdown only.",
|
||||
"shape": {"note": "result_schema not yet implemented for this agent"},
|
||||
},
|
||||
"daily-briefing": {
|
||||
"description": "Aggregated daily briefing: wiki doc posted + each sub-agent's result nested under sections.",
|
||||
"shape": {
|
||||
"date": "YYYY-MM-DD",
|
||||
"generated_at": "ISO datetime string",
|
||||
"person": "string",
|
||||
"location": "object (same shape as weather.location)",
|
||||
"sections": "object keyed by sub-agent (weather|calendar|reminders|notes|reading_list|projects), each value is {name, summary, result, error?}",
|
||||
"wiki_doc_id": "string (Outline doc id) or null",
|
||||
"wiki_action": "'created' | 'updated' | null",
|
||||
},
|
||||
},
|
||||
"project-monitor": {
|
||||
"description": "LLM-generated status report for a tracked project.",
|
||||
"shape": {
|
||||
"project_name": "string",
|
||||
"app_url": "string or null",
|
||||
"wiki_collection_id": "string or null",
|
||||
"wiki_report_id": "string or null (Outline doc id of the full report)",
|
||||
"gitea_repo": "string or null",
|
||||
"summary": "string (first paragraph of the report)",
|
||||
"report_markdown": "string (full LLM-generated report)",
|
||||
"model": "string (LLM model used)",
|
||||
"tokens": {"input": "int", "output": "int"},
|
||||
"generated_at": "ISO datetime string",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _seed_result_schemas(db: Session):
|
||||
"""Populate agent_catalog.result_schema for known agents. Idempotent — only fills empty."""
|
||||
for catalog_id, schema in RESULT_SCHEMAS.items():
|
||||
entry = db.query(AgentCatalog).filter(AgentCatalog.id == catalog_id).first()
|
||||
if entry and not entry.result_schema:
|
||||
entry.result_schema = schema
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Startup ---
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
init_db()
|
||||
# Seed result schemas for catalog entries that don't have them yet
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_seed_result_schemas(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
import os
|
||||
|
||||
@@ -19,5 +19,19 @@ def get_db():
|
||||
db.close()
|
||||
|
||||
|
||||
def _ensure_column(conn, table: str, column: str, ddl: str):
|
||||
"""Add a column to an existing table if it doesn't exist (SQLite idempotent migration)."""
|
||||
insp = inspect(conn)
|
||||
cols = {c["name"] for c in insp.get_columns(table)}
|
||||
if column not in cols:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
|
||||
print(f" migration: added {table}.{column}")
|
||||
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
# Additive columns on existing tables (safe to re-run)
|
||||
_ensure_column(conn, "runs", "result", "JSON")
|
||||
_ensure_column(conn, "runs", "triggered_by", "VARCHAR DEFAULT ''")
|
||||
_ensure_column(conn, "agent_catalog", "result_schema", "JSON")
|
||||
|
||||
+44
-1
@@ -31,6 +31,7 @@ class AgentCatalog(Base):
|
||||
supports_schedule = Column(Boolean, default=True)
|
||||
is_sub_agent = Column(Boolean, default=False)
|
||||
requires_llm = Column(Boolean, default=False)
|
||||
result_schema = Column(JSON, default=dict) # shape of the agent's structured result
|
||||
|
||||
instances = relationship("AgentInstance", back_populates="catalog_entry")
|
||||
|
||||
@@ -61,9 +62,11 @@ class Run(Base):
|
||||
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
status = Column(String, default="running")
|
||||
output = Column(Text, default="")
|
||||
output = Column(Text, default="") # markdown rendering (for wiki)
|
||||
result = Column(JSON, nullable=True) # structured data for API consumers
|
||||
error = Column(Text, default="")
|
||||
metadata_ = Column("metadata", JSON, default=dict)
|
||||
triggered_by = Column(String, default="") # "user:eric", "api_client:synap", "cron"
|
||||
|
||||
instance = relationship("AgentInstance", back_populates="runs")
|
||||
|
||||
@@ -109,3 +112,43 @@ class LLMProvider(Base):
|
||||
api_key = Column(String, default="")
|
||||
default_model = Column(String, default="")
|
||||
is_default = Column(Boolean, default=False)
|
||||
|
||||
|
||||
class APIClient(Base):
|
||||
"""App-level API client. Each external app (Synap, WSIT, etc.) gets one.
|
||||
Scoped to specific agent instances — see APIClientScope."""
|
||||
__tablename__ = "api_clients"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, nullable=False, unique=True) # "Synap", "WSIT"
|
||||
token_hash = Column(String, nullable=False, unique=True) # SHA-256 of the plaintext token
|
||||
token_prefix = Column(String, default="") # first 8 chars of token, shown in admin UI
|
||||
description = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
|
||||
scopes = relationship("APIClientScope", back_populates="client", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class APIClientScope(Base):
|
||||
"""Join table: which instances can an API client trigger/read?"""
|
||||
__tablename__ = "api_client_scopes"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
api_client_id = Column(Integer, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False)
|
||||
instance_id = Column(Integer, ForeignKey("agent_instances.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
client = relationship("APIClient", back_populates="scopes")
|
||||
|
||||
|
||||
class APIClientCall(Base):
|
||||
"""Audit log for every authenticated API call by an API client."""
|
||||
__tablename__ = "api_client_calls"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
api_client_id = Column(Integer, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False)
|
||||
instance_id = Column(Integer, nullable=True) # may be null for non-instance endpoints
|
||||
endpoint = Column(String, default="") # e.g. "POST /api/instances/2/trigger"
|
||||
status_code = Column(Integer, default=0)
|
||||
called_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
+165
-1
@@ -65,6 +65,7 @@ tr:hover td{background:var(--surface2)}
|
||||
<div class="tab" onclick="switchTab('catalog')">Agent Catalog</div>
|
||||
<div class="tab" onclick="switchTab('llm')">LLM Providers</div>
|
||||
<div class="tab" onclick="switchTab('bridges')">Bridges</div>
|
||||
<div class="tab" onclick="switchTab('api-clients')">API Clients</div>
|
||||
<div class="tab" onclick="switchTab('system')">System</div>
|
||||
</div>
|
||||
|
||||
@@ -136,6 +137,48 @@ tr:hover td{background:var(--surface2)}
|
||||
<table id="bridges-table"><thead><tr><th>User</th><th>Hostname</th><th>URL</th><th>Platform</th><th>Status</th><th>Last Heartbeat</th><th>Capabilities</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- API Clients -->
|
||||
<div class="panel" id="panel-api-clients">
|
||||
<div class="form-card">
|
||||
<h3>Create API Client</h3>
|
||||
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
|
||||
Issues a bearer token that an external app (Synap, WSIT, etc.) can use to trigger agent instances and read results.
|
||||
Tokens are scoped to specific instances — see the
|
||||
<a href="https://wiki.jfamily.io/doc/api-clients-token-scoping-KhtWinIzMT" target="_blank" style="color:var(--accent)">token scoping doc</a>.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<div class="field"><label>Name</label><input id="nac-name" placeholder="Synap"></div>
|
||||
<div class="field"><label>Description</label><input id="nac-desc" placeholder="What this app uses the token for"></div>
|
||||
</div>
|
||||
<div class="form-row full">
|
||||
<div class="field">
|
||||
<label>Authorized Instances</label>
|
||||
<div id="nac-instances" style="max-height:200px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.5rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="createApiClient()">Create</button>
|
||||
<span class="msg" id="nac-msg"></span>
|
||||
</div>
|
||||
|
||||
<!-- Token reveal modal (shown once after create/rotate) -->
|
||||
<div id="token-reveal" class="form-card" style="display:none;border-color:var(--yellow);background:rgba(253,203,110,.05)">
|
||||
<h3 style="color:var(--yellow)">New token — copy it now</h3>
|
||||
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
|
||||
This is the only time you will see this token. Copy it into the app's environment now. If you lose it, rotate the client to issue a new one.
|
||||
</p>
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<code id="token-reveal-value" style="flex:1;padding:.6rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;overflow-x:auto;white-space:nowrap"></code>
|
||||
<button class="btn btn-primary btn-sm" onclick="copyToken()">Copy</button>
|
||||
<button class="btn btn-sm small-btn" onclick="document.getElementById('token-reveal').style.display='none'">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="api-clients-table">
|
||||
<thead><tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
<div class="panel" id="panel-system">
|
||||
<div class="stat-grid" id="sys-stats"></div>
|
||||
@@ -232,6 +275,127 @@ async function loadBridges(){
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// --- API Clients ---
|
||||
let _allInstances=[]; // cache
|
||||
let _lastToken=''; // for copy button
|
||||
|
||||
async function loadAllInstancesForPicker(selected){
|
||||
selected=selected||new Set();
|
||||
const res=await fetch(API+'/api/admin/instances');
|
||||
if(!res.ok)return;
|
||||
_allInstances=await res.json();
|
||||
const container=document.getElementById('nac-instances');
|
||||
if(!_allInstances.length){container.innerHTML='<div style="color:var(--text-dim);font-size:.85rem">No instances exist yet — create one first.</div>';return}
|
||||
// Group by user
|
||||
const byUser={};
|
||||
_allInstances.forEach(i=>{(byUser[i.username]=byUser[i.username]||[]).push(i)});
|
||||
container.innerHTML=Object.keys(byUser).sort().map(un=>`
|
||||
<div style="margin-bottom:.5rem"><div style="font-size:.75rem;color:var(--text-dim);text-transform:uppercase;margin-bottom:.25rem">${un}</div>
|
||||
${byUser[un].map(i=>`<label style="display:flex;align-items:center;gap:.5rem;padding:.25rem 0;font-size:.85rem;cursor:pointer">
|
||||
<input type="checkbox" value="${i.id}" class="nac-inst-cb" ${selected.has(i.id)?'checked':''}>
|
||||
<span><code style="color:var(--accent)">${i.catalog_id}</code> · ${i.name} <span style="color:var(--text-dim)">(id ${i.id})</span></span>
|
||||
</label>`).join('')}</div>`).join('');
|
||||
}
|
||||
|
||||
async function loadApiClients(){
|
||||
const res=await fetch(API+'/api/admin/api-clients');
|
||||
if(!res.ok)return;
|
||||
const clients=await res.json();
|
||||
const tbody=document.querySelector('#api-clients-table tbody');
|
||||
if(!clients.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">No API clients yet</td></tr>';return}
|
||||
tbody.innerHTML=clients.map(c=>{
|
||||
const status=c.revoked?'<span class="badge admin">revoked</span>':'<span class="badge user">active</span>';
|
||||
const scopeLabels=(c.instance_ids||[]).map(id=>{
|
||||
const inst=_allInstances.find(i=>i.id===id);
|
||||
return inst?`${inst.username}/${inst.catalog_id}(${id})`:`#${id}`;
|
||||
}).join(', ')||'<span style="color:var(--red)">none</span>';
|
||||
const actions=c.revoked
|
||||
? `<button class="btn btn-sm small-btn" onclick="rotateApiClient(${c.id})">Rotate</button> <button class="btn btn-danger btn-sm" onclick="deleteApiClient(${c.id},'${c.name}')">Delete</button>`
|
||||
: `<button class="btn btn-sm small-btn" onclick="editApiClient(${c.id})">Edit</button> <button class="btn btn-sm small-btn" onclick="rotateApiClient(${c.id})">Rotate</button> <button class="btn btn-danger btn-sm" onclick="revokeApiClient(${c.id})">Revoke</button>`;
|
||||
return `<tr>
|
||||
<td><strong>${c.name}</strong>${c.description?`<div style="font-size:.75rem;color:var(--text-dim)">${c.description}</div>`:''}</td>
|
||||
<td><code style="font-size:.75rem">${c.token_prefix||'-'}…</code></td>
|
||||
<td style="font-size:.75rem">${scopeLabels}</td>
|
||||
<td style="font-size:.75rem">${c.created_at?new Date(c.created_at).toLocaleDateString():'-'}</td>
|
||||
<td style="font-size:.75rem">${c.last_used_at?new Date(c.last_used_at).toLocaleString():'<span style="color:var(--text-dim)">never</span>'}</td>
|
||||
<td>${status}</td>
|
||||
<td><div class="action-btns">${actions}</div></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function createApiClient(){
|
||||
const name=document.getElementById('nac-name').value.trim();
|
||||
const desc=document.getElementById('nac-desc').value.trim();
|
||||
const instance_ids=[...document.querySelectorAll('.nac-inst-cb:checked')].map(cb=>parseInt(cb.value));
|
||||
if(!name){showMsg('nac-msg','Name is required',false);return}
|
||||
if(!instance_ids.length){showMsg('nac-msg','Pick at least one instance to scope the token',false);return}
|
||||
const res=await fetch(API+'/api/admin/api-clients',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name,description:desc,instance_ids})});
|
||||
if(res.ok){
|
||||
const c=await res.json();
|
||||
revealToken(c.token);
|
||||
showMsg('nac-msg','Created',true);
|
||||
document.getElementById('nac-name').value='';
|
||||
document.getElementById('nac-desc').value='';
|
||||
loadAllInstancesForPicker();
|
||||
loadApiClients();
|
||||
} else {
|
||||
const e=await res.json();showMsg('nac-msg',e.detail||'Error',false);
|
||||
}
|
||||
}
|
||||
|
||||
function revealToken(token){
|
||||
_lastToken=token;
|
||||
document.getElementById('token-reveal-value').textContent=token;
|
||||
document.getElementById('token-reveal').style.display='block';
|
||||
document.getElementById('token-reveal').scrollIntoView({behavior:'smooth',block:'center'});
|
||||
}
|
||||
|
||||
function copyToken(){
|
||||
navigator.clipboard.writeText(_lastToken).then(()=>{
|
||||
const btn=event.target;const orig=btn.textContent;btn.textContent='Copied!';setTimeout(()=>btn.textContent=orig,1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeApiClient(id){
|
||||
if(!confirm('Revoke this token? The app using it will stop working immediately.'))return;
|
||||
await fetch(API+'/api/admin/api-clients/'+id+'/revoke',{method:'POST'});
|
||||
loadApiClients();
|
||||
}
|
||||
|
||||
async function rotateApiClient(id){
|
||||
if(!confirm('Issue a new token? The old one will stop working immediately.'))return;
|
||||
const res=await fetch(API+'/api/admin/api-clients/'+id+'/rotate',{method:'POST'});
|
||||
if(res.ok){
|
||||
const c=await res.json();
|
||||
revealToken(c.token);
|
||||
loadApiClients();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiClient(id,name){
|
||||
if(!confirm('Permanently delete client '+name+'?'))return;
|
||||
await fetch(API+'/api/admin/api-clients/'+id,{method:'DELETE'});
|
||||
loadApiClients();
|
||||
}
|
||||
|
||||
async function editApiClient(id){
|
||||
const res=await fetch(API+'/api/admin/api-clients');
|
||||
const clients=await res.json();
|
||||
const c=clients.find(x=>x.id===id);
|
||||
if(!c)return;
|
||||
const existing=new Set(c.instance_ids||[]);
|
||||
const checkboxes=[..._allInstances].map(i=>`${i.id}: ${i.username}/${i.catalog_id} (${i.name})${existing.has(i.id)?' [currently authorized]':''}`).join('\n');
|
||||
const input=prompt(`Edit scopes for "${c.name}"\n\nEnter comma-separated instance IDs this token should be allowed to access.\n\nAvailable instances:\n${checkboxes}`, [...existing].join(','));
|
||||
if(input===null)return;
|
||||
const ids=input.split(',').map(s=>parseInt(s.trim())).filter(n=>!isNaN(n));
|
||||
const res2=await fetch(API+'/api/admin/api-clients/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({instance_ids:ids})});
|
||||
if(res2.ok)loadApiClients();
|
||||
else{const e=await res2.json();alert(e.detail||'Error')}
|
||||
}
|
||||
|
||||
// --- System ---
|
||||
async function loadSystem(){
|
||||
const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]);
|
||||
@@ -244,7 +408,7 @@ async function loadSystem(){
|
||||
}
|
||||
|
||||
// Init
|
||||
loadUsers();loadCatalog();loadProviders();loadBridges();loadSystem();
|
||||
loadUsers();loadCatalog();loadProviders();loadBridges();loadAllInstancesForPicker().then(loadApiClients);loadSystem();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user