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 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_forgejo import router as agent_forgejo_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.include_router(auth_router)
|
||||
api_v1.include_router(bot_router)
|
||||
api_v1.include_router(agent_router)
|
||||
api_v1.include_router(agents_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";
|
||||
import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel";
|
||||
import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel";
|
||||
import { BotStatusSection } from "@/components/dashboard/BotStatusSection";
|
||||
import {
|
||||
type listAgentsApiV1AgentsGetResponse,
|
||||
useListAgentsApiV1AgentsGet,
|
||||
|
|
@ -1462,6 +1463,10 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<BotStatusSection />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<SessionsSection
|
||||
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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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