bot
This commit is contained in:
parent
d65e888ade
commit
077a07b233
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""Bot API key management and project report endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||||
|
from sqlmodel import col, desc, select
|
||||||
|
|
||||||
|
from app.api.deps import require_org_admin, require_org_member
|
||||||
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
|
||||||
|
from app.core.time import utcnow
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.bot import BotApiKey, BotReport
|
||||||
|
from app.schemas.bot import (
|
||||||
|
BotApiKeyCreate,
|
||||||
|
BotApiKeyCreated,
|
||||||
|
BotApiKeyRead,
|
||||||
|
BotReportCreate,
|
||||||
|
BotReportRead,
|
||||||
|
)
|
||||||
|
from app.services.organizations import OrganizationContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bot", tags=["bot"])
|
||||||
|
|
||||||
|
SESSION_DEP = Depends(get_session)
|
||||||
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||||
|
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_bot_key_auth(
|
||||||
|
x_bot_key: str | None = Header(default=None, alias="X-Bot-Key"),
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
) -> BotApiKey:
|
||||||
|
"""Validate X-Bot-Key header against stored hashes. Returns the matching key row."""
|
||||||
|
if not x_bot_key:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="X-Bot-Key header required.")
|
||||||
|
keys = list(await session.exec(select(BotApiKey)))
|
||||||
|
for key in keys:
|
||||||
|
if verify_agent_token(x_bot_key, key.key_hash):
|
||||||
|
key.last_used_at = utcnow()
|
||||||
|
session.add(key)
|
||||||
|
await session.commit()
|
||||||
|
return key
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bot key.")
|
||||||
|
|
||||||
|
|
||||||
|
BOT_KEY_DEP = Depends(_get_bot_key_auth)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Key management (user auth)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/keys", response_model=BotApiKeyCreated, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_bot_key(
|
||||||
|
data: BotApiKeyCreate,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||||
|
) -> BotApiKeyCreated:
|
||||||
|
"""Generate a new bot API key. The plaintext key is returned once — store it immediately."""
|
||||||
|
plaintext = generate_agent_token()
|
||||||
|
key = BotApiKey(
|
||||||
|
organization_id=ctx.organization.id,
|
||||||
|
name=data.name,
|
||||||
|
key_hash=hash_agent_token(plaintext),
|
||||||
|
key_last_four=plaintext[-4:],
|
||||||
|
)
|
||||||
|
session.add(key)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(key)
|
||||||
|
return BotApiKeyCreated(
|
||||||
|
id=key.id,
|
||||||
|
name=key.name,
|
||||||
|
key_last_four=key.key_last_four,
|
||||||
|
created_at=key.created_at,
|
||||||
|
last_used_at=key.last_used_at,
|
||||||
|
key=plaintext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/keys", response_model=list[BotApiKeyRead])
|
||||||
|
async def list_bot_keys(
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> list[BotApiKeyRead]:
|
||||||
|
"""List all bot API keys for this organization."""
|
||||||
|
keys = list(await session.exec(
|
||||||
|
select(BotApiKey)
|
||||||
|
.where(col(BotApiKey.organization_id) == ctx.organization.id)
|
||||||
|
.order_by(desc(col(BotApiKey.created_at)))
|
||||||
|
))
|
||||||
|
return [BotApiKeyRead.model_validate(k, from_attributes=True) for k in keys]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_bot_key(
|
||||||
|
key_id: UUID,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||||
|
) -> None:
|
||||||
|
"""Revoke a bot API key."""
|
||||||
|
key = await session.get(BotApiKey, key_id)
|
||||||
|
if key is None or key.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
await session.delete(key)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reporting (bot key auth)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/report", response_model=BotReportRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_bot_report(
|
||||||
|
data: BotReportCreate,
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
key: BotApiKey = BOT_KEY_DEP,
|
||||||
|
) -> BotReportRead:
|
||||||
|
"""Record what the bot is currently working on."""
|
||||||
|
report = BotReport(
|
||||||
|
organization_id=key.organization_id,
|
||||||
|
key_id=key.id,
|
||||||
|
project=data.project,
|
||||||
|
task=data.task,
|
||||||
|
status=data.status,
|
||||||
|
detail=data.detail,
|
||||||
|
)
|
||||||
|
session.add(report)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(report)
|
||||||
|
return BotReportRead.model_validate(report, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Report reading (user auth)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/reports/latest", response_model=BotReportRead)
|
||||||
|
async def get_latest_bot_report(
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> BotReportRead:
|
||||||
|
"""Return the most recent bot report for this organization."""
|
||||||
|
report = (await session.exec(
|
||||||
|
select(BotReport)
|
||||||
|
.where(col(BotReport.organization_id) == ctx.organization.id)
|
||||||
|
.order_by(desc(col(BotReport.reported_at)))
|
||||||
|
.limit(1)
|
||||||
|
)).first()
|
||||||
|
if report is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
return BotReportRead.model_validate(report, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports", response_model=list[BotReportRead])
|
||||||
|
async def list_bot_reports(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
session: AsyncSession = SESSION_DEP,
|
||||||
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
|
) -> list[BotReportRead]:
|
||||||
|
"""List bot reports newest-first."""
|
||||||
|
reports = list(await session.exec(
|
||||||
|
select(BotReport)
|
||||||
|
.where(col(BotReport.organization_id) == ctx.organization.id)
|
||||||
|
.order_by(desc(col(BotReport.reported_at)))
|
||||||
|
.limit(limit)
|
||||||
|
))
|
||||||
|
return [BotReportRead.model_validate(r, from_attributes=True) for r in reports]
|
||||||
|
|
@ -12,6 +12,7 @@ from fastapi.openapi.utils import get_openapi
|
||||||
from fastapi_pagination import add_pagination
|
from fastapi_pagination import add_pagination
|
||||||
|
|
||||||
from app.api.activity import router as activity_router
|
from app.api.activity import router as activity_router
|
||||||
|
from app.api.bot import router as bot_router
|
||||||
from app.api.agent import router as agent_router
|
from app.api.agent import router as agent_router
|
||||||
from app.api.agent_forgejo import router as agent_forgejo_router
|
from app.api.agent_forgejo import router as agent_forgejo_router
|
||||||
from app.api.agent_sessions import router as agent_sessions_router
|
from app.api.agent_sessions import router as agent_sessions_router
|
||||||
|
|
@ -607,6 +608,7 @@ def readyz() -> HealthStatusResponse:
|
||||||
|
|
||||||
api_v1 = APIRouter(prefix="/api/v1")
|
api_v1 = APIRouter(prefix="/api/v1")
|
||||||
api_v1.include_router(auth_router)
|
api_v1.include_router(auth_router)
|
||||||
|
api_v1.include_router(bot_router)
|
||||||
api_v1.include_router(agent_router)
|
api_v1.include_router(agent_router)
|
||||||
api_v1.include_router(agents_router)
|
api_v1.include_router(agents_router)
|
||||||
api_v1.include_router(activity_router)
|
api_v1.include_router(activity_router)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Models for bot API keys and project reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Column
|
||||||
|
from sqlmodel import Field
|
||||||
|
|
||||||
|
from app.core.time import utcnow
|
||||||
|
from app.models.base import QueryModel
|
||||||
|
|
||||||
|
|
||||||
|
class BotApiKey(QueryModel, table=True):
|
||||||
|
__tablename__ = "bot_api_keys" # pyright: ignore[reportAssignmentType]
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
||||||
|
name: str = Field(index=True)
|
||||||
|
key_hash: str = Field(index=True)
|
||||||
|
key_last_four: str
|
||||||
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
last_used_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class BotReport(QueryModel, table=True):
|
||||||
|
__tablename__ = "bot_reports" # pyright: ignore[reportAssignmentType]
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
||||||
|
key_id: UUID = Field(foreign_key="bot_api_keys.id", index=True)
|
||||||
|
project: str | None = Field(default=None)
|
||||||
|
task: str | None = Field(default=None)
|
||||||
|
status: str | None = Field(default=None)
|
||||||
|
detail: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON))
|
||||||
|
reported_at: datetime = Field(default_factory=utcnow, index=True)
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Schemas for bot API keys and project reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class BotApiKeyCreate(SQLModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class BotApiKeyRead(SQLModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
key_last_four: str
|
||||||
|
created_at: datetime
|
||||||
|
last_used_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class BotApiKeyCreated(BotApiKeyRead):
|
||||||
|
"""Returned once on creation — includes the plaintext key, never stored."""
|
||||||
|
key: str
|
||||||
|
|
||||||
|
|
||||||
|
class BotReportCreate(SQLModel):
|
||||||
|
project: str | None = None
|
||||||
|
task: str | None = None
|
||||||
|
status: str | None = "busy"
|
||||||
|
detail: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BotReportRead(SQLModel):
|
||||||
|
id: UUID
|
||||||
|
key_id: UUID
|
||||||
|
project: str | None
|
||||||
|
task: str | None
|
||||||
|
status: str | None
|
||||||
|
detail: dict[str, Any] | None
|
||||||
|
reported_at: datetime
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""add bot api keys and reports
|
||||||
|
|
||||||
|
Revision ID: d5e6f7a8b9c0
|
||||||
|
Revises: c6d7e8f9a0b1
|
||||||
|
Create Date: 2026-05-26 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
revision = "d5e6f7a8b9c0"
|
||||||
|
down_revision = "c6d7e8f9a0b1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
|
||||||
|
if not inspector.has_table("bot_api_keys"):
|
||||||
|
op.create_table(
|
||||||
|
"bot_api_keys",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("organization_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("key_hash", sa.String(), nullable=False),
|
||||||
|
sa.Column("key_last_four", sa.String(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_bot_api_keys_organization_id", "bot_api_keys", ["organization_id"])
|
||||||
|
op.create_index("ix_bot_api_keys_key_hash", "bot_api_keys", ["key_hash"])
|
||||||
|
op.create_index("ix_bot_api_keys_name", "bot_api_keys", ["name"])
|
||||||
|
|
||||||
|
if not inspector.has_table("bot_reports"):
|
||||||
|
op.create_table(
|
||||||
|
"bot_reports",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("organization_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("key_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("project", sa.String(), nullable=True),
|
||||||
|
sa.Column("task", sa.String(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(), nullable=True),
|
||||||
|
sa.Column("detail", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("reported_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["key_id"], ["bot_api_keys.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_bot_reports_organization_id", "bot_reports", ["organization_id"])
|
||||||
|
op.create_index("ix_bot_reports_key_id", "bot_reports", ["key_id"])
|
||||||
|
op.create_index("ix_bot_reports_reported_at", "bot_reports", ["reported_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("bot_reports")
|
||||||
|
op.drop_table("bot_api_keys")
|
||||||
|
|
@ -63,6 +63,7 @@ import {
|
||||||
} from "@/components/dashboard/RuntimeUsageSection";
|
} from "@/components/dashboard/RuntimeUsageSection";
|
||||||
import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel";
|
import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel";
|
||||||
import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel";
|
import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel";
|
||||||
|
import { BotStatusSection } from "@/components/dashboard/BotStatusSection";
|
||||||
import {
|
import {
|
||||||
type listAgentsApiV1AgentsGetResponse,
|
type listAgentsApiV1AgentsGetResponse,
|
||||||
useListAgentsApiV1AgentsGet,
|
useListAgentsApiV1AgentsGet,
|
||||||
|
|
@ -1462,6 +1463,10 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<BotStatusSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<SessionsSection
|
<SessionsSection
|
||||||
sessions={sessionSummaries}
|
sessions={sessionSummaries}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Bot, Check, Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
listBotKeys,
|
||||||
|
createBotKey,
|
||||||
|
deleteBotKey,
|
||||||
|
type BotApiKeyRead,
|
||||||
|
type BotApiKeyCreated,
|
||||||
|
} from "@/lib/api/bot";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// One-time key reveal dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function KeyRevealDialog({
|
||||||
|
created,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
created: BotApiKeyCreated | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
if (!created) return;
|
||||||
|
void navigator.clipboard.writeText(created.key);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}, [created]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!created} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>API Key Generated</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Copy this key now — it will not be shown again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-[color:rgba(251,191,36,0.35)] bg-[color:rgba(251,191,36,0.08)] p-3">
|
||||||
|
<code className="flex-1 break-all font-mono text-sm text-strong">
|
||||||
|
{created?.key}
|
||||||
|
</code>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleCopy} className="shrink-0 px-2">
|
||||||
|
{copied ? <Check className="h-4 w-4 text-[color:var(--success)]" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Add this to Ripley's <code className="font-mono">TOOLS.md</code> as{" "}
|
||||||
|
<code className="font-mono">PIPELINE_BOT_KEY={created?.key}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button onClick={onClose}>Done</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Key row
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function KeyRow({
|
||||||
|
apiKey,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
apiKey: BotApiKeyRead;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteBotKey(apiKey.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["bot-keys"] });
|
||||||
|
setConfirmOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdAt = new Date(apiKey.created_at).toLocaleDateString();
|
||||||
|
const lastUsed = apiKey.last_used_at
|
||||||
|
? new Date(apiKey.last_used_at).toLocaleString()
|
||||||
|
: "Never";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<KeyRound className="h-4 w-4 shrink-0 text-muted" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-strong">{apiKey.name}</p>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
ending ••••{apiKey.key_last_four} · created {createdAt} · last used {lastUsed}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0 px-2 text-muted hover:text-[color:var(--danger)]"
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
title="Revoke API key?"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
This will permanently revoke <strong>{apiKey.name}</strong>{" "}
|
||||||
|
(ending ••••{apiKey.key_last_four}). Any scripts using this key will stop working immediately.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
isConfirming={deleteMutation.isPending}
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
confirmVariant="primary"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function BotApiKeysPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [createdKey, setCreatedKey] = useState<BotApiKeyCreated | null>(null);
|
||||||
|
|
||||||
|
const keysQuery = useQuery({
|
||||||
|
queryKey: ["bot-keys"],
|
||||||
|
queryFn: listBotKeys,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createBotKey(name.trim()),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCreatedKey(data);
|
||||||
|
setName("");
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["bot-keys"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(() => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
createMutation.mutate();
|
||||||
|
}, [name, createMutation]);
|
||||||
|
|
||||||
|
const keys = keysQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardPageLayout
|
||||||
|
title="Bot API Keys"
|
||||||
|
description="Generate API keys so the OpenClaw agent (Ripley) can report its current project to the dashboard."
|
||||||
|
signedOut={{
|
||||||
|
message: "Sign in to manage bot API keys.",
|
||||||
|
forceRedirectUrl: "/settings/bot-api-keys",
|
||||||
|
signUpForceRedirectUrl: "/settings/bot-api-keys",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
|
||||||
|
{/* Generate section */}
|
||||||
|
<section className="rounded-xl border border-[color:var(--border)] p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="h-5 w-5 text-[color:var(--warning)]" />
|
||||||
|
<h2 className="text-base font-semibold text-strong">Generate a new key</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Give the key a name (e.g. "Ripley"), then click Generate. The plaintext key
|
||||||
|
is shown once — copy it immediately.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Key name, e.g. Ripley"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleGenerate(); }}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!name.trim() || createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{createMutation.isError && (
|
||||||
|
<p className="text-sm text-[color:var(--danger)]">Failed to generate key. Try again.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Key list */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-semibold text-strong">Active keys</h2>
|
||||||
|
{keysQuery.isLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-muted text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!keysQuery.isLoading && keys.length === 0 && (
|
||||||
|
<p className="text-sm text-muted">No keys yet. Generate one above.</p>
|
||||||
|
)}
|
||||||
|
{keys.map((k) => (
|
||||||
|
<KeyRow key={k.id} apiKey={k} onDelete={() => void queryClient.invalidateQueries({ queryKey: ["bot-keys"] })} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KeyRevealDialog created={createdKey} onClose={() => setCreatedKey(null)} />
|
||||||
|
</DashboardPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -510,6 +510,13 @@ export default function SettingsPage() {
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
tone: "warning",
|
tone: "warning",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/settings/bot-api-keys",
|
||||||
|
label: "Bot API keys",
|
||||||
|
description: "Generate and manage API keys for the OpenClaw agent (Ripley).",
|
||||||
|
icon: Bot,
|
||||||
|
tone: "neutral",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const operationItems: SettingsLink[] = isAdmin
|
const operationItems: SettingsLink[] = isAdmin
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bot, Clock } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { getLatestBotReport, type BotReportRead } from "@/lib/api/bot";
|
||||||
|
import { DashboardSection } from "./DashboardSection";
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string | null }) {
|
||||||
|
if (!status) return null;
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
busy: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
|
||||||
|
idle: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
|
||||||
|
error: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
|
||||||
|
};
|
||||||
|
const style = styles[status.toLowerCase()] ?? "bg-[color:var(--surface-strong)] text-muted";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${style}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportContent({ report }: { report: BotReportRead }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-2xl font-semibold text-strong truncate">
|
||||||
|
{report.project ?? "—"}
|
||||||
|
</p>
|
||||||
|
{report.task && (
|
||||||
|
<p className="mt-1 text-sm text-muted truncate">{report.task}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={report.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{timeAgo(report.reported_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm text-muted">
|
||||||
|
<Bot className="h-4 w-4 shrink-0" />
|
||||||
|
Ripley hasn't reported in yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotStatusSection() {
|
||||||
|
const { data: report } = useQuery({
|
||||||
|
queryKey: ["bot-report-latest"],
|
||||||
|
queryFn: getLatestBotReport,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardSection
|
||||||
|
title="Ripley"
|
||||||
|
tone="warning"
|
||||||
|
action={{ label: "Manage keys", href: "/settings/bot-api-keys" }}
|
||||||
|
infoText="Current project reported by the OpenClaw agent"
|
||||||
|
>
|
||||||
|
{report ? <ReportContent report={report} /> : <EmptyState />}
|
||||||
|
</DashboardSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { customFetch } from "@/api/mutator";
|
||||||
|
|
||||||
|
type ApiResponse<T> = {
|
||||||
|
data: T;
|
||||||
|
status: number;
|
||||||
|
headers: Headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotApiKeyRead = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_last_four: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotApiKeyCreated = BotApiKeyRead & {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotReportRead = {
|
||||||
|
id: string;
|
||||||
|
key_id: string;
|
||||||
|
project: string | null;
|
||||||
|
task: string | null;
|
||||||
|
status: string | null;
|
||||||
|
detail: Record<string, unknown> | null;
|
||||||
|
reported_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listBotKeys(): Promise<BotApiKeyRead[]> {
|
||||||
|
const response = await customFetch<ApiResponse<BotApiKeyRead[]>>(
|
||||||
|
"/api/v1/bot/keys",
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBotKey(name: string): Promise<BotApiKeyCreated> {
|
||||||
|
const response = await customFetch<ApiResponse<BotApiKeyCreated>>(
|
||||||
|
"/api/v1/bot/keys",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBotKey(id: string): Promise<void> {
|
||||||
|
await customFetch<ApiResponse<void>>(`/api/v1/bot/keys/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBotReports(limit = 50): Promise<BotReportRead[]> {
|
||||||
|
const response = await customFetch<ApiResponse<BotReportRead[]>>(
|
||||||
|
`/api/v1/bot/reports?limit=${limit}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestBotReport(): Promise<BotReportRead | null> {
|
||||||
|
try {
|
||||||
|
const response = await customFetch<ApiResponse<BotReportRead>>(
|
||||||
|
"/api/v1/bot/reports/latest",
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Report the current project/task to the Pipeline dashboard.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python report-project.py --project Pipeline --task "fixing bug" --status busy
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
PIPELINE_BOT_KEY API key generated from Settings → Bot API keys
|
||||||
|
PIPELINE_URL Pipeline base URL (default: http://localhost:8001)
|
||||||
|
|
||||||
|
Optional environment variables:
|
||||||
|
PIPELINE_REPORT_TIMEOUT Request timeout in seconds (default: 3)
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 success or env vars not configured (non-disruptive)
|
||||||
|
1 HTTP error or unexpected failure
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Report current project/task to Pipeline dashboard."
|
||||||
|
)
|
||||||
|
parser.add_argument("--project", default=None, help="Project name, e.g. Pipeline")
|
||||||
|
parser.add_argument("--task", default=None, help="Current task description")
|
||||||
|
parser.add_argument(
|
||||||
|
"--status",
|
||||||
|
default="busy",
|
||||||
|
choices=["busy", "idle", "error"],
|
||||||
|
help="Agent status (default: busy)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--detail", default=None, help="Extra JSON detail string")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
bot_key = os.environ.get("PIPELINE_BOT_KEY", "").strip()
|
||||||
|
base_url = os.environ.get("PIPELINE_URL", "http://localhost:8001").strip().rstrip("/")
|
||||||
|
timeout = float(os.environ.get("PIPELINE_REPORT_TIMEOUT", "3"))
|
||||||
|
|
||||||
|
if not bot_key:
|
||||||
|
# Not configured — exit silently so the agent isn't disrupted.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"project": args.project,
|
||||||
|
"task": args.task,
|
||||||
|
"status": args.status,
|
||||||
|
}
|
||||||
|
if args.detail:
|
||||||
|
try:
|
||||||
|
payload["detail"] = json.loads(args.detail)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload["detail"] = {"raw": args.detail}
|
||||||
|
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
url = f"{base_url}/api/v1/bot/report"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"X-Bot-Key": bot_key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "PipelineBotReport/1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout):
|
||||||
|
pass
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
print(f"report-project: HTTP {exc.code} from {url}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except (urllib.error.URLError, OSError) as exc:
|
||||||
|
print(f"report-project: connection error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Reference in New Issue