refactor: clean up code formatting and improve readability across multiple files
This commit is contained in:
@@ -229,9 +229,7 @@ async def _fetch_agent_events(
|
|||||||
return list(await session.exec(statement))
|
return list(await session.exec(statement))
|
||||||
|
|
||||||
|
|
||||||
async def _require_user_context(
|
async def _require_user_context(session: AsyncSession, user: User | None) -> OrganizationContext:
|
||||||
session: AsyncSession, user: User | None
|
|
||||||
) -> OrganizationContext:
|
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
member = await get_active_membership(session, user)
|
member = await get_active_membership(session, user)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from app.models.gateways import Gateway
|
|||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
|
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
|
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||||
from app.services.organizations import (
|
from app.services.organizations import (
|
||||||
OrganizationContext,
|
OrganizationContext,
|
||||||
is_org_admin,
|
is_org_admin,
|
||||||
@@ -40,7 +41,6 @@ from app.services.organizations import (
|
|||||||
member_all_boards_read,
|
member_all_boards_read,
|
||||||
member_all_boards_write,
|
member_all_boards_write,
|
||||||
)
|
)
|
||||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
|
||||||
|
|
||||||
router = APIRouter(tags=["board-group-memory"])
|
router = APIRouter(tags=["board-group-memory"])
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,7 @@ async def list_board_groups(
|
|||||||
ctx=Depends(require_org_member),
|
ctx=Depends(require_org_member),
|
||||||
) -> DefaultLimitOffsetPage[BoardGroupRead]:
|
) -> DefaultLimitOffsetPage[BoardGroupRead]:
|
||||||
if member_all_boards_read(ctx.member):
|
if member_all_boards_read(ctx.member):
|
||||||
statement = select(BoardGroup).where(
|
statement = select(BoardGroup).where(col(BoardGroup.organization_id) == ctx.organization.id)
|
||||||
col(BoardGroup.organization_id) == ctx.organization.id
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
accessible_boards = select(Board.board_group_id).where(
|
accessible_boards = select(Board.board_group_id).where(
|
||||||
board_access_filter(ctx.member, write=False)
|
board_access_filter(ctx.member, write=False)
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ async def _apply_board_update(
|
|||||||
) -> Board:
|
) -> Board:
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
if "gateway_id" in updates:
|
if "gateway_id" in updates:
|
||||||
await _require_gateway(session, updates["gateway_id"], organization_id=board.organization_id)
|
await _require_gateway(
|
||||||
|
session, updates["gateway_id"], organization_id=board.organization_id
|
||||||
|
)
|
||||||
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
||||||
await _require_board_group(
|
await _require_board_group(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ from app.core.auth import AuthContext, get_auth_context, get_auth_context_option
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
from app.models.organizations import Organization
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
from app.models.organizations import Organization
|
from app.services.admin_access import require_admin
|
||||||
from app.services.organizations import (
|
from app.services.organizations import (
|
||||||
OrganizationContext,
|
OrganizationContext,
|
||||||
ensure_member_for_user,
|
ensure_member_for_user,
|
||||||
@@ -21,7 +22,6 @@ from app.services.organizations import (
|
|||||||
is_org_admin,
|
is_org_admin,
|
||||||
require_board_access,
|
require_board_access,
|
||||||
)
|
)
|
||||||
from app.services.admin_access import require_admin
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthContext:
|
def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthContext:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from app.integrations.openclaw_gateway_protocol import (
|
|||||||
)
|
)
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
from app.services.organizations import OrganizationContext, require_board_access
|
|
||||||
from app.schemas.common import OkResponse
|
from app.schemas.common import OkResponse
|
||||||
from app.schemas.gateway_api import (
|
from app.schemas.gateway_api import (
|
||||||
GatewayCommandsResponse,
|
GatewayCommandsResponse,
|
||||||
@@ -32,6 +31,7 @@ from app.schemas.gateway_api import (
|
|||||||
GatewaySessionsResponse,
|
GatewaySessionsResponse,
|
||||||
GatewaysStatusResponse,
|
GatewaysStatusResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.organizations import OrganizationContext, require_board_access
|
||||||
|
|
||||||
router = APIRouter(prefix="/gateways", tags=["gateways"])
|
router = APIRouter(prefix="/gateways", tags=["gateways"])
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.models.organizations import Organization
|
|||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
from app.schemas.organizations import (
|
from app.schemas.organizations import (
|
||||||
OrganizationActiveUpdate,
|
OrganizationActiveUpdate,
|
||||||
|
OrganizationBoardAccessRead,
|
||||||
OrganizationCreate,
|
OrganizationCreate,
|
||||||
OrganizationInviteAccept,
|
OrganizationInviteAccept,
|
||||||
OrganizationInviteCreate,
|
OrganizationInviteCreate,
|
||||||
@@ -31,7 +32,6 @@ from app.schemas.organizations import (
|
|||||||
OrganizationMemberAccessUpdate,
|
OrganizationMemberAccessUpdate,
|
||||||
OrganizationMemberRead,
|
OrganizationMemberRead,
|
||||||
OrganizationMemberUpdate,
|
OrganizationMemberUpdate,
|
||||||
OrganizationBoardAccessRead,
|
|
||||||
OrganizationRead,
|
OrganizationRead,
|
||||||
OrganizationUserRead,
|
OrganizationUserRead,
|
||||||
)
|
)
|
||||||
@@ -39,8 +39,8 @@ from app.schemas.pagination import DefaultLimitOffsetPage
|
|||||||
from app.services.organizations import (
|
from app.services.organizations import (
|
||||||
OrganizationContext,
|
OrganizationContext,
|
||||||
accept_invite,
|
accept_invite,
|
||||||
apply_invite_to_member,
|
|
||||||
apply_invite_board_access,
|
apply_invite_board_access,
|
||||||
|
apply_invite_to_member,
|
||||||
apply_member_access_update,
|
apply_member_access_update,
|
||||||
get_active_membership,
|
get_active_membership,
|
||||||
get_member,
|
get_member,
|
||||||
@@ -298,9 +298,7 @@ async def create_org_invite(
|
|||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||||
|
|
||||||
existing_user = (
|
existing_user = (
|
||||||
await session.exec(
|
await session.exec(select(User).where(func.lower(col(User.email)) == email))
|
||||||
select(User).where(func.lower(col(User.email)) == email)
|
|
||||||
)
|
|
||||||
).first()
|
).first()
|
||||||
if existing_user is not None:
|
if existing_user is not None:
|
||||||
existing_member = await get_member(
|
existing_member = await get_member(
|
||||||
@@ -380,7 +378,9 @@ async def accept_org_invite(
|
|||||||
if invite is None:
|
if invite is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
if invite.invited_email and auth.user.email:
|
if invite.invited_email and auth.user.email:
|
||||||
if normalize_invited_email(invite.invited_email) != normalize_invited_email(auth.user.email):
|
if normalize_invited_email(invite.invited_email) != normalize_invited_email(
|
||||||
|
auth.user.email
|
||||||
|
):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
existing = await get_member(
|
existing = await get_member(
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ class OrganizationBoardAccess(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
organization_member_id: UUID = Field(
|
organization_member_id: UUID = Field(foreign_key="organization_members.id", index=True)
|
||||||
foreign_key="organization_members.id", index=True
|
|
||||||
)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
||||||
can_read: bool = Field(default=True)
|
can_read: bool = Field(default=True)
|
||||||
can_write: bool = Field(default=False)
|
can_write: bool = Field(default=False)
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ class OrganizationInviteBoardAccess(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
organization_invite_id: UUID = Field(
|
organization_invite_id: UUID = Field(foreign_key="organization_invites.id", index=True)
|
||||||
foreign_key="organization_invites.id", index=True
|
|
||||||
)
|
|
||||||
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
board_id: UUID = Field(foreign_key="boards.id", index=True)
|
||||||
can_read: bool = Field(default=True)
|
can_read: bool = Field(default=True)
|
||||||
can_write: bool = Field(default=False)
|
can_write: bool = Field(default=False)
|
||||||
|
|||||||
@@ -79,9 +79,7 @@ async def set_active_organization(
|
|||||||
user: User,
|
user: User,
|
||||||
organization_id: UUID,
|
organization_id: UUID,
|
||||||
) -> OrganizationMember:
|
) -> OrganizationMember:
|
||||||
member = await get_member(
|
member = await get_member(session, user_id=user.id, organization_id=organization_id)
|
||||||
session, user_id=user.id, organization_id=organization_id
|
|
||||||
)
|
|
||||||
if member is None:
|
if member is None:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access")
|
||||||
if user.active_organization_id != organization_id:
|
if user.active_organization_id != organization_id:
|
||||||
@@ -199,8 +197,7 @@ async def ensure_member_for_user(session: AsyncSession, user: User) -> Organizat
|
|||||||
now = utcnow()
|
now = utcnow()
|
||||||
member_count = (
|
member_count = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
select(func.count())
|
select(func.count()).where(col(OrganizationMember.organization_id) == org.id)
|
||||||
.where(col(OrganizationMember.organization_id) == org.id)
|
|
||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
is_first = int(member_count or 0) == 0
|
is_first = int(member_count or 0) == 0
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` pub
|
|||||||
If you see repeated proxy errors (often `ECONNRESET`), make sure your dev server hostname and browser URL match (e.g. `localhost` vs `127.0.0.1`), and that your origin is included in `allowedDevOrigins`.
|
If you see repeated proxy errors (often `ECONNRESET`), make sure your dev server hostname and browser URL match (e.g. `localhost` vs `127.0.0.1`), and that your origin is included in `allowedDevOrigins`.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Local dev should work via `http://localhost:3000` and `http://127.0.0.1:3000`.
|
- Local dev should work via `http://localhost:3000` and `http://127.0.0.1:3000`.
|
||||||
- LAN dev should work via the configured LAN IP (e.g. `http://192.168.1.101:3000`) **only** if you bind the dev server to all interfaces (`npm run dev:lan`).
|
- LAN dev should work via the configured LAN IP (e.g. `http://192.168.1.101:3000`) **only** if you bind the dev server to all interfaces (`npm run dev:lan`).
|
||||||
- If you bind Next to `127.0.0.1` only, remote LAN clients won’t connect.
|
- If you bind Next to `127.0.0.1` only, remote LAN clients won’t connect.
|
||||||
|
|||||||
@@ -212,190 +212,193 @@ export default function AgentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
|
||||||
Agents
|
Agents
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-2xl font-semibold text-strong">
|
<h1 className="text-2xl font-semibold text-strong">
|
||||||
{agent?.name ?? "Agent"}
|
{agent?.name ?? "Agent"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Review agent health, session binding, and recent activity.
|
Review agent health, session binding, and recent activity.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" onClick={() => router.push("/agents")}>
|
|
||||||
Back to agents
|
|
||||||
</Button>
|
|
||||||
{agent ? (
|
|
||||||
<Link
|
|
||||||
href={`/agents/${agent.id}/edit`}
|
|
||||||
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
{agent ? (
|
|
||||||
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
|
||||||
Loading agent details…
|
|
||||||
</div>
|
|
||||||
) : agent ? (
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Overview
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold text-strong">
|
|
||||||
{agent.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={agentStatus} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Agent ID
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted">{agent.id}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Session key
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted">
|
|
||||||
{agent.openclaw_session_id ?? "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Board
|
|
||||||
</p>
|
|
||||||
{agent.is_gateway_main ? (
|
|
||||||
<p className="mt-1 text-sm text-strong">
|
|
||||||
Gateway main (no board)
|
|
||||||
</p>
|
|
||||||
) : linkedBoard ? (
|
|
||||||
<Link
|
|
||||||
href={`/boards/${linkedBoard.id}`}
|
|
||||||
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
|
|
||||||
>
|
|
||||||
{linkedBoard.name}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<p className="mt-1 text-sm text-strong">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Last seen
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-strong">
|
|
||||||
{formatRelative(agent.last_seen_at)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-quiet">
|
|
||||||
{formatTimestamp(agent.last_seen_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Updated
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted">
|
|
||||||
{formatTimestamp(agent.updated_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Created
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted">
|
|
||||||
{formatTimestamp(agent.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
|
||||||
Health
|
|
||||||
</p>
|
|
||||||
<StatusPill status={agentStatus} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-3 text-sm text-muted">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Heartbeat window</span>
|
|
||||||
<span>{formatRelative(agent.last_seen_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Session binding</span>
|
|
||||||
<span>
|
|
||||||
{agent.openclaw_session_id ? "Bound" : "Unbound"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Status</span>
|
|
||||||
<span className="text-strong">{agentStatus}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/agents")}
|
||||||
|
>
|
||||||
|
Back to agents
|
||||||
|
</Button>
|
||||||
|
{agent ? (
|
||||||
|
<Link
|
||||||
|
href={`/agents/${agent.id}/edit`}
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{agent ? (
|
||||||
|
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
|
{error ? (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
{error}
|
||||||
Activity
|
</div>
|
||||||
</p>
|
) : null}
|
||||||
<p className="text-xs text-quiet">
|
|
||||||
{agentEvents.length} events
|
{isLoading ? (
|
||||||
</p>
|
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
||||||
</div>
|
Loading agent details…
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{agentEvents.length === 0 ? (
|
) : agent ? (
|
||||||
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
|
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
No activity yet for this agent.
|
<div className="space-y-6">
|
||||||
</div>
|
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
|
||||||
) : (
|
<div className="flex items-center justify-between">
|
||||||
agentEvents.map((event) => (
|
<div>
|
||||||
<div
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
key={event.id}
|
Overview
|
||||||
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
|
|
||||||
>
|
|
||||||
<p className="font-medium text-strong">
|
|
||||||
{event.message ?? event.event_type}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-quiet">
|
<p className="mt-1 text-lg font-semibold text-strong">
|
||||||
{formatTimestamp(event.created_at)}
|
{agent.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
<StatusPill status={agentStatus} />
|
||||||
)}
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Agent ID
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">{agent.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Session key
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{agent.openclaw_session_id ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Board
|
||||||
|
</p>
|
||||||
|
{agent.is_gateway_main ? (
|
||||||
|
<p className="mt-1 text-sm text-strong">
|
||||||
|
Gateway main (no board)
|
||||||
|
</p>
|
||||||
|
) : linkedBoard ? (
|
||||||
|
<Link
|
||||||
|
href={`/boards/${linkedBoard.id}`}
|
||||||
|
className="mt-1 inline-flex text-sm font-medium text-[color:var(--accent)] transition hover:underline"
|
||||||
|
>
|
||||||
|
{linkedBoard.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-sm text-strong">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Last seen
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-strong">
|
||||||
|
{formatRelative(agent.last_seen_at)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-quiet">
|
||||||
|
{formatTimestamp(agent.last_seen_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Updated
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{formatTimestamp(agent.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Created
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{formatTimestamp(agent.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Health
|
||||||
|
</p>
|
||||||
|
<StatusPill status={agentStatus} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 text-sm text-muted">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Heartbeat window</span>
|
||||||
|
<span>{formatRelative(agent.last_seen_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Session binding</span>
|
||||||
|
<span>
|
||||||
|
{agent.openclaw_session_id ? "Bound" : "Unbound"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Status</span>
|
||||||
|
<span className="text-strong">{agentStatus}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
|
||||||
|
Activity
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-quiet">
|
||||||
|
{agentEvents.length} events
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{agentEvents.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
|
||||||
|
No activity yet for this agent.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
agentEvents.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-strong">
|
||||||
|
{event.message ?? event.event_type}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-quiet">
|
||||||
|
{formatTimestamp(event.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
||||||
<div className="flex flex-1 items-center justify-center text-sm text-muted">
|
Agent not found.
|
||||||
Agent not found.
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|||||||
@@ -214,184 +214,189 @@ export default function NewAgentPage() {
|
|||||||
Basic configuration
|
Basic configuration
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-6">
|
<div className="mt-4 space-y-6">
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Agent name <span className="text-red-500">*</span>
|
Agent name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
placeholder="e.g. Deploy bot"
|
placeholder="e.g. Deploy bot"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={identityProfile.role}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIdentityProfile((current) => ({
|
||||||
|
...current,
|
||||||
|
role: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Founder, Social Media Manager"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Board <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<SearchableSelect
|
||||||
|
ariaLabel="Select board"
|
||||||
|
value={displayBoardId}
|
||||||
|
onValueChange={setBoardId}
|
||||||
|
options={getBoardOptions(boards)}
|
||||||
|
placeholder="Select board"
|
||||||
|
searchPlaceholder="Search boards..."
|
||||||
|
emptyMessage="No matching boards."
|
||||||
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
|
disabled={boards.length === 0}
|
||||||
|
/>
|
||||||
|
{boards.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Create a board before adding agents.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Emoji
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={identityProfile.emoji}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setIdentityProfile((current) => ({
|
||||||
|
...current,
|
||||||
|
emoji: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select emoji" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EMOJI_OPTIONS.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.glyph} {option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Personality & behavior
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Role
|
Communication style
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={identityProfile.role}
|
value={identityProfile.communication_style}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setIdentityProfile((current) => ({
|
setIdentityProfile((current) => ({
|
||||||
...current,
|
...current,
|
||||||
role: event.target.value,
|
communication_style: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="e.g. Founder, Social Media Manager"
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Soul template
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={soulTemplate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSoulTemplate(event.target.value)
|
||||||
|
}
|
||||||
|
rows={10}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Schedule & notifications
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Board <span className="text-red-500">*</span>
|
Interval
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={heartbeatEvery}
|
||||||
|
onChange={(event) =>
|
||||||
|
setHeartbeatEvery(event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="e.g. 10m"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Target
|
||||||
</label>
|
</label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
ariaLabel="Select board"
|
ariaLabel="Select heartbeat target"
|
||||||
value={displayBoardId}
|
value={heartbeatTarget}
|
||||||
onValueChange={setBoardId}
|
onValueChange={setHeartbeatTarget}
|
||||||
options={getBoardOptions(boards)}
|
options={HEARTBEAT_TARGET_OPTIONS}
|
||||||
placeholder="Select board"
|
placeholder="Select target"
|
||||||
searchPlaceholder="Search boards..."
|
searchPlaceholder="Search targets..."
|
||||||
emptyMessage="No matching boards."
|
emptyMessage="No matching targets."
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
disabled={boards.length === 0}
|
|
||||||
/>
|
|
||||||
{boards.length === 0 ? (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Create a board before adding agents.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Emoji
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={identityProfile.emoji}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setIdentityProfile((current) => ({
|
|
||||||
...current,
|
|
||||||
emoji: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
/>
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select emoji" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{EMOJI_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.glyph} {option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{errorMessage ? (
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||||
Personality & behavior
|
{errorMessage}
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Communication style
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={identityProfile.communication_style}
|
|
||||||
onChange={(event) =>
|
|
||||||
setIdentityProfile((current) => ({
|
|
||||||
...current,
|
|
||||||
communication_style: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Soul template
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={soulTemplate}
|
|
||||||
onChange={(event) => setSoulTemplate(event.target.value)}
|
|
||||||
rows={10}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Creating…" : "Create agent"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/agents")}
|
||||||
|
>
|
||||||
|
Back to agents
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Schedule & notifications
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Interval
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={heartbeatEvery}
|
|
||||||
onChange={(event) =>
|
|
||||||
setHeartbeatEvery(event.target.value)
|
|
||||||
}
|
|
||||||
placeholder="e.g. 10m"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Target
|
|
||||||
</label>
|
|
||||||
<SearchableSelect
|
|
||||||
ariaLabel="Select heartbeat target"
|
|
||||||
value={heartbeatTarget}
|
|
||||||
onValueChange={setHeartbeatTarget}
|
|
||||||
options={HEARTBEAT_TARGET_OPTIONS}
|
|
||||||
placeholder="Select target"
|
|
||||||
searchPlaceholder="Search targets..."
|
|
||||||
emptyMessage="No matching targets."
|
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Button type="submit" disabled={isLoading}>
|
|
||||||
{isLoading ? "Creating…" : "Create agent"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push("/agents")}
|
|
||||||
>
|
|
||||||
Back to agents
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -350,88 +350,88 @@ export default function AgentsPage() {
|
|||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th key={header.id} className="px-6 py-3">
|
<th key={header.id} className="px-6 py-3">
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</thead>
|
||||||
))}
|
<tbody className="divide-y divide-slate-100">
|
||||||
</thead>
|
{agentsQuery.isLoading ? (
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tr>
|
||||||
{agentsQuery.isLoading ? (
|
<td colSpan={columns.length} className="px-6 py-8">
|
||||||
<tr>
|
<span className="text-sm text-slate-500">
|
||||||
<td colSpan={columns.length} className="px-6 py-8">
|
Loading…
|
||||||
<span className="text-sm text-slate-500">
|
</span>
|
||||||
Loading…
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-slate-50">
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td key={cell.id} className="px-6 py-4">
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
</tr>
|
) : table.getRowModel().rows.length ? (
|
||||||
))
|
table.getRowModel().rows.map((row) => (
|
||||||
) : (
|
<tr key={row.id} className="hover:bg-slate-50">
|
||||||
<tr>
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td colSpan={columns.length} className="px-6 py-16">
|
<td key={cell.id} className="px-6 py-4">
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
{flexRender(
|
||||||
<div className="mb-4 rounded-full bg-slate-50 p-4">
|
cell.column.columnDef.cell,
|
||||||
<svg
|
cell.getContext(),
|
||||||
className="h-16 w-16 text-slate-300"
|
)}
|
||||||
viewBox="0 0 24 24"
|
</td>
|
||||||
fill="none"
|
))}
|
||||||
stroke="currentColor"
|
</tr>
|
||||||
strokeWidth="1.5"
|
))
|
||||||
strokeLinecap="round"
|
) : (
|
||||||
strokeLinejoin="round"
|
<tr>
|
||||||
>
|
<td colSpan={columns.length} className="px-6 py-16">
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
<circle cx="9" cy="7" r="4" />
|
<div className="mb-4 rounded-full bg-slate-50 p-4">
|
||||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
<svg
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
className="h-16 w-16 text-slate-300"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
fill="none"
|
||||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
stroke="currentColor"
|
||||||
No agents yet
|
strokeWidth="1.5"
|
||||||
</h3>
|
strokeLinecap="round"
|
||||||
<p className="mb-6 max-w-md text-sm text-slate-500">
|
strokeLinejoin="round"
|
||||||
Create your first agent to start executing tasks
|
>
|
||||||
on this board.
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
</p>
|
<circle cx="9" cy="7" r="4" />
|
||||||
<Link
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
href="/agents/new"
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
className={buttonVariants({
|
</svg>
|
||||||
size: "md",
|
</div>
|
||||||
variant: "primary",
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||||
})}
|
No agents yet
|
||||||
>
|
</h3>
|
||||||
Create your first agent
|
<p className="mb-6 max-w-md text-sm text-slate-500">
|
||||||
</Link>
|
Create your first agent to start executing
|
||||||
</div>
|
tasks on this board.
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
<Link
|
||||||
)}
|
href="/agents/new"
|
||||||
</tbody>
|
className={buttonVariants({
|
||||||
</table>
|
size: "md",
|
||||||
</div>
|
variant: "primary",
|
||||||
</div>
|
})}
|
||||||
|
>
|
||||||
|
Create your first agent
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{agentsQuery.error ? (
|
{agentsQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">
|
<p className="mt-4 text-sm text-red-500">
|
||||||
|
|||||||
@@ -710,7 +710,13 @@ export default function BoardGroupDetailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsHeartbeatApplying(false);
|
setIsHeartbeatApplying(false);
|
||||||
}
|
}
|
||||||
}, [canManageHeartbeat, groupId, heartbeatEvery, includeBoardLeads, isSignedIn]);
|
}, [
|
||||||
|
canManageHeartbeat,
|
||||||
|
groupId,
|
||||||
|
heartbeatEvery,
|
||||||
|
includeBoardLeads,
|
||||||
|
isSignedIn,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell>
|
<DashboardShell>
|
||||||
@@ -850,7 +856,8 @@ export default function BoardGroupDetailPage() {
|
|||||||
heartbeatEvery === value
|
heartbeatEvery === value
|
||||||
? "bg-slate-900 text-white"
|
? "bg-slate-900 text-white"
|
||||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
|
||||||
!canManageHeartbeat && "opacity-50 cursor-not-allowed",
|
!canManageHeartbeat &&
|
||||||
|
"opacity-50 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
disabled={!canManageHeartbeat}
|
disabled={!canManageHeartbeat}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -913,7 +920,9 @@ export default function BoardGroupDetailPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void applyHeartbeat()}
|
onClick={() => void applyHeartbeat()}
|
||||||
disabled={
|
disabled={
|
||||||
isHeartbeatApplying || !heartbeatEvery || !canManageHeartbeat
|
isHeartbeatApplying ||
|
||||||
|
!heartbeatEvery ||
|
||||||
|
!canManageHeartbeat
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
canManageHeartbeat
|
canManageHeartbeat
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ const resolveBoardAccess = (
|
|||||||
if (member.all_boards_read) {
|
if (member.all_boards_read) {
|
||||||
return { canRead: true, canWrite: false };
|
return { canRead: true, canWrite: false };
|
||||||
}
|
}
|
||||||
const entry = member.board_access?.find((access) => access.board_id === boardId);
|
const entry = member.board_access?.find(
|
||||||
|
(access) => access.board_id === boardId,
|
||||||
|
);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return { canRead: false, canWrite: false };
|
return { canRead: false, canWrite: false };
|
||||||
}
|
}
|
||||||
@@ -2199,7 +2201,9 @@ export default function BoardDetailPage() {
|
|||||||
async (approvalId: string, status: "approved" | "rejected") => {
|
async (approvalId: string, status: "approved" | "rejected") => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
if (!canWrite) {
|
if (!canWrite) {
|
||||||
pushToast("Read-only access. You do not have permission to update approvals.");
|
pushToast(
|
||||||
|
"Read-only access. You do not have permission to update approvals.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setApprovalsUpdatingId(approvalId);
|
setApprovalsUpdatingId(approvalId);
|
||||||
@@ -3033,7 +3037,9 @@ export default function BoardDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handlePostComment}
|
onClick={handlePostComment}
|
||||||
disabled={!canWrite || isPostingComment || !newComment.trim()}
|
disabled={
|
||||||
|
!canWrite || isPostingComment || !newComment.trim()
|
||||||
|
}
|
||||||
title={canWrite ? "Send message" : "Read-only access"}
|
title={canWrite ? "Send message" : "Read-only access"}
|
||||||
>
|
>
|
||||||
{isPostingComment ? "Sending…" : "Send message"}
|
{isPostingComment ? "Sending…" : "Send message"}
|
||||||
@@ -3516,7 +3522,10 @@ export default function BoardDetailPage() {
|
|||||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreateTask} disabled={!canWrite || isCreating}>
|
<Button
|
||||||
|
onClick={handleCreateTask}
|
||||||
|
disabled={!canWrite || isCreating}
|
||||||
|
>
|
||||||
{isCreating ? "Creating…" : "Create task"}
|
{isCreating ? "Creating…" : "Create task"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -3611,9 +3620,7 @@ export default function BoardDetailPage() {
|
|||||||
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
|
toast.tone === "error" ? "bg-rose-500" : "bg-emerald-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<p className="flex-1 text-sm text-slate-700">
|
<p className="flex-1 text-sm text-slate-700">{toast.message}</p>
|
||||||
{toast.message}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs text-slate-400 hover:text-slate-600"
|
className="text-xs text-slate-400 hover:text-slate-600"
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export default function NewBoardPage() {
|
|||||||
{isLoading ? "Creating…" : "Create board"}
|
{isLoading ? "Creating…" : "Create board"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -269,121 +269,123 @@ export default function EditGatewayPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Gateway URL <span className="text-red-500">*</span>
|
Gateway URL <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={resolvedGatewayUrl}
|
||||||
|
onChange={(event) => {
|
||||||
|
setGatewayUrl(event.target.value);
|
||||||
|
setGatewayUrlError(null);
|
||||||
|
setGatewayCheckStatus("idle");
|
||||||
|
setGatewayCheckMessage(null);
|
||||||
|
}}
|
||||||
|
onBlur={runGatewayCheck}
|
||||||
|
placeholder="ws://gateway:18789"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={
|
||||||
|
gatewayUrlError ? "border-red-500" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runGatewayCheck}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
aria-label="Check gateway connection"
|
||||||
|
>
|
||||||
|
{gatewayCheckStatus === "checking" ? (
|
||||||
|
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||||
|
) : gatewayCheckStatus === "success" ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : gatewayCheckStatus === "error" ? (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{gatewayUrlError ? (
|
||||||
|
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||||
|
) : gatewayCheckMessage ? (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
gatewayCheckStatus === "success"
|
||||||
|
? "text-xs text-emerald-600"
|
||||||
|
: "text-xs text-red-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{gatewayCheckMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Gateway token
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={resolvedGatewayUrl}
|
value={resolvedGatewayToken}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setGatewayUrl(event.target.value);
|
setGatewayToken(event.target.value);
|
||||||
setGatewayUrlError(null);
|
|
||||||
setGatewayCheckStatus("idle");
|
setGatewayCheckStatus("idle");
|
||||||
setGatewayCheckMessage(null);
|
setGatewayCheckMessage(null);
|
||||||
}}
|
}}
|
||||||
onBlur={runGatewayCheck}
|
onBlur={runGatewayCheck}
|
||||||
placeholder="ws://gateway:18789"
|
placeholder="Bearer token"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={gatewayUrlError ? "border-red-500" : undefined}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={runGatewayCheck}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
|
||||||
aria-label="Check gateway connection"
|
|
||||||
>
|
|
||||||
{gatewayCheckStatus === "checking" ? (
|
|
||||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
|
||||||
) : gatewayCheckStatus === "success" ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
||||||
) : gatewayCheckStatus === "error" ? (
|
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
|
||||||
) : (
|
|
||||||
<RefreshCcw className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{gatewayUrlError ? (
|
|
||||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
|
||||||
) : gatewayCheckMessage ? (
|
|
||||||
<p
|
|
||||||
className={
|
|
||||||
gatewayCheckStatus === "success"
|
|
||||||
? "text-xs text-emerald-600"
|
|
||||||
: "text-xs text-red-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{gatewayCheckMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Gateway token
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={resolvedGatewayToken}
|
|
||||||
onChange={(event) => {
|
|
||||||
setGatewayToken(event.target.value);
|
|
||||||
setGatewayCheckStatus("idle");
|
|
||||||
setGatewayCheckMessage(null);
|
|
||||||
}}
|
|
||||||
onBlur={runGatewayCheck}
|
|
||||||
placeholder="Bearer token"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Main session key <span className="text-red-500">*</span>
|
Main session key <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={resolvedMainSessionKey}
|
value={resolvedMainSessionKey}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setMainSessionKey(event.target.value);
|
setMainSessionKey(event.target.value);
|
||||||
setGatewayCheckStatus("idle");
|
setGatewayCheckStatus("idle");
|
||||||
setGatewayCheckMessage(null);
|
setGatewayCheckMessage(null);
|
||||||
}}
|
}}
|
||||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Workspace root <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={resolvedWorkspaceRoot}
|
||||||
|
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||||
|
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
{errorMessage ? (
|
||||||
Workspace root <span className="text-red-500">*</span>
|
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||||
</label>
|
) : null}
|
||||||
<Input
|
|
||||||
value={resolvedWorkspaceRoot}
|
<div className="flex justify-end gap-3">
|
||||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
<Button
|
||||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push("/gateways")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || !canSubmit}>
|
||||||
|
{isLoading ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.push("/gateways")}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isLoading || !canSubmit}>
|
|
||||||
{isLoading ? "Saving…" : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -230,119 +230,121 @@ export default function NewGatewayPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Gateway URL <span className="text-red-500">*</span>
|
Gateway URL <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={gatewayUrl}
|
||||||
|
onChange={(event) => {
|
||||||
|
setGatewayUrl(event.target.value);
|
||||||
|
setGatewayUrlError(null);
|
||||||
|
setGatewayCheckStatus("idle");
|
||||||
|
setGatewayCheckMessage(null);
|
||||||
|
}}
|
||||||
|
onBlur={runGatewayCheck}
|
||||||
|
placeholder="ws://gateway:18789"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={
|
||||||
|
gatewayUrlError ? "border-red-500" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runGatewayCheck}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
aria-label="Check gateway connection"
|
||||||
|
>
|
||||||
|
{gatewayCheckStatus === "checking" ? (
|
||||||
|
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||||
|
) : gatewayCheckStatus === "success" ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : gatewayCheckStatus === "error" ? (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{gatewayUrlError ? (
|
||||||
|
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||||
|
) : gatewayCheckMessage ? (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
gatewayCheckStatus === "success"
|
||||||
|
? "text-xs text-emerald-600"
|
||||||
|
: "text-xs text-red-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{gatewayCheckMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Gateway token
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={gatewayUrl}
|
value={gatewayToken}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setGatewayUrl(event.target.value);
|
setGatewayToken(event.target.value);
|
||||||
setGatewayUrlError(null);
|
|
||||||
setGatewayCheckStatus("idle");
|
setGatewayCheckStatus("idle");
|
||||||
setGatewayCheckMessage(null);
|
setGatewayCheckMessage(null);
|
||||||
}}
|
}}
|
||||||
onBlur={runGatewayCheck}
|
onBlur={runGatewayCheck}
|
||||||
placeholder="ws://gateway:18789"
|
placeholder="Bearer token"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={gatewayUrlError ? "border-red-500" : undefined}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={runGatewayCheck}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
|
||||||
aria-label="Check gateway connection"
|
|
||||||
>
|
|
||||||
{gatewayCheckStatus === "checking" ? (
|
|
||||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
|
||||||
) : gatewayCheckStatus === "success" ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
||||||
) : gatewayCheckStatus === "error" ? (
|
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
|
||||||
) : (
|
|
||||||
<RefreshCcw className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{gatewayUrlError ? (
|
|
||||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
|
||||||
) : gatewayCheckMessage ? (
|
|
||||||
<p
|
|
||||||
className={
|
|
||||||
gatewayCheckStatus === "success"
|
|
||||||
? "text-xs text-emerald-600"
|
|
||||||
: "text-xs text-red-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{gatewayCheckMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Gateway token
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={gatewayToken}
|
|
||||||
onChange={(event) => {
|
|
||||||
setGatewayToken(event.target.value);
|
|
||||||
setGatewayCheckStatus("idle");
|
|
||||||
setGatewayCheckMessage(null);
|
|
||||||
}}
|
|
||||||
onBlur={runGatewayCheck}
|
|
||||||
placeholder="Bearer token"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Main session key <span className="text-red-500">*</span>
|
Main session key <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={mainSessionKey}
|
value={mainSessionKey}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setMainSessionKey(event.target.value);
|
setMainSessionKey(event.target.value);
|
||||||
setGatewayCheckStatus("idle");
|
setGatewayCheckStatus("idle");
|
||||||
setGatewayCheckMessage(null);
|
setGatewayCheckMessage(null);
|
||||||
}}
|
}}
|
||||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Workspace root <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={workspaceRoot}
|
||||||
|
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||||
|
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||||
Workspace root <span className="text-red-500">*</span>
|
|
||||||
</label>
|
<div className="flex justify-end gap-3">
|
||||||
<Input
|
<Button
|
||||||
value={workspaceRoot}
|
type="button"
|
||||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
variant="ghost"
|
||||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
onClick={() => router.push("/gateways")}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || !canSubmit}>
|
||||||
|
{isLoading ? "Creating…" : "Create gateway"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.push("/gateways")}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isLoading || !canSubmit}>
|
|
||||||
{isLoading ? "Creating…" : "Create gateway"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -283,93 +283,93 @@ export default function GatewaysPage() {
|
|||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th key={header.id} className="px-6 py-3">
|
<th key={header.id} className="px-6 py-3">
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</thead>
|
||||||
))}
|
<tbody className="divide-y divide-slate-100">
|
||||||
</thead>
|
{gatewaysQuery.isLoading ? (
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tr>
|
||||||
{gatewaysQuery.isLoading ? (
|
<td colSpan={columns.length} className="px-6 py-8">
|
||||||
<tr>
|
<span className="text-sm text-slate-500">
|
||||||
<td colSpan={columns.length} className="px-6 py-8">
|
Loading…
|
||||||
<span className="text-sm text-slate-500">
|
</span>
|
||||||
Loading…
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-slate-50">
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td key={cell.id} className="px-6 py-4">
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
</tr>
|
) : table.getRowModel().rows.length ? (
|
||||||
))
|
table.getRowModel().rows.map((row) => (
|
||||||
) : (
|
<tr key={row.id} className="hover:bg-slate-50">
|
||||||
<tr>
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td colSpan={columns.length} className="px-6 py-16">
|
<td key={cell.id} className="px-6 py-4">
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
{flexRender(
|
||||||
<div className="mb-4 rounded-full bg-slate-50 p-4">
|
cell.column.columnDef.cell,
|
||||||
<svg
|
cell.getContext(),
|
||||||
className="h-16 w-16 text-slate-300"
|
)}
|
||||||
viewBox="0 0 24 24"
|
</td>
|
||||||
fill="none"
|
))}
|
||||||
stroke="currentColor"
|
</tr>
|
||||||
strokeWidth="1.5"
|
))
|
||||||
strokeLinecap="round"
|
) : (
|
||||||
strokeLinejoin="round"
|
<tr>
|
||||||
>
|
<td colSpan={columns.length} className="px-6 py-16">
|
||||||
<rect
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
x="2"
|
<div className="mb-4 rounded-full bg-slate-50 p-4">
|
||||||
y="7"
|
<svg
|
||||||
width="20"
|
className="h-16 w-16 text-slate-300"
|
||||||
height="14"
|
viewBox="0 0 24 24"
|
||||||
rx="2"
|
fill="none"
|
||||||
ry="2"
|
stroke="currentColor"
|
||||||
/>
|
strokeWidth="1.5"
|
||||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
strokeLinecap="round"
|
||||||
</svg>
|
strokeLinejoin="round"
|
||||||
</div>
|
>
|
||||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
<rect
|
||||||
No gateways yet
|
x="2"
|
||||||
</h3>
|
y="7"
|
||||||
<p className="mb-6 max-w-md text-sm text-slate-500">
|
width="20"
|
||||||
Create your first gateway to connect boards and
|
height="14"
|
||||||
start managing your OpenClaw connections.
|
rx="2"
|
||||||
</p>
|
ry="2"
|
||||||
<Link
|
/>
|
||||||
href="/gateways/new"
|
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||||
className={buttonVariants({
|
</svg>
|
||||||
size: "md",
|
</div>
|
||||||
variant: "primary",
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||||
})}
|
No gateways yet
|
||||||
>
|
</h3>
|
||||||
Create your first gateway
|
<p className="mb-6 max-w-md text-sm text-slate-500">
|
||||||
</Link>
|
Create your first gateway to connect boards
|
||||||
</div>
|
and start managing your OpenClaw connections.
|
||||||
</td>
|
</p>
|
||||||
</tr>
|
<Link
|
||||||
)}
|
href="/gateways/new"
|
||||||
</tbody>
|
className={buttonVariants({
|
||||||
</table>
|
size: "md",
|
||||||
</div>
|
variant: "primary",
|
||||||
</div>
|
})}
|
||||||
|
>
|
||||||
|
Create your first gateway
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{gatewaysQuery.error ? (
|
{gatewaysQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">
|
<p className="mt-4 text-sm text-red-500">
|
||||||
|
|||||||
@@ -114,8 +114,15 @@ export default function InvitePage() {
|
|||||||
</SignedOut>
|
</SignedOut>
|
||||||
|
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<form className="flex flex-wrap items-center gap-3" onSubmit={handleAccept}>
|
<form
|
||||||
<Button type="submit" size="md" disabled={!isReady || isSubmitting || accepted}>
|
className="flex flex-wrap items-center gap-3"
|
||||||
|
onSubmit={handleAccept}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="md"
|
||||||
|
disabled={!isReady || isSubmitting || accepted}
|
||||||
|
>
|
||||||
{accepted
|
{accepted
|
||||||
? "Invite accepted"
|
? "Invite accepted"
|
||||||
: isSubmitting
|
: isSubmitting
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ type AccessScope = "all" | "custom";
|
|||||||
|
|
||||||
type BoardAccessState = Record<string, { read: boolean; write: boolean }>;
|
type BoardAccessState = Record<string, { read: boolean; write: boolean }>;
|
||||||
|
|
||||||
const buildAccessList = (access: BoardAccessState): OrganizationBoardAccessSpec[] =>
|
const buildAccessList = (
|
||||||
|
access: BoardAccessState,
|
||||||
|
): OrganizationBoardAccessSpec[] =>
|
||||||
Object.entries(access)
|
Object.entries(access)
|
||||||
.filter(([, entry]) => entry.read || entry.write)
|
.filter(([, entry]) => entry.read || entry.write)
|
||||||
.map(([boardId, entry]) => ({
|
.map(([boardId, entry]) => ({
|
||||||
@@ -319,9 +321,8 @@ export default function OrganizationPage() {
|
|||||||
const [inviteScope, setInviteScope] = useState<AccessScope>("all");
|
const [inviteScope, setInviteScope] = useState<AccessScope>("all");
|
||||||
const [inviteAllRead, setInviteAllRead] = useState(true);
|
const [inviteAllRead, setInviteAllRead] = useState(true);
|
||||||
const [inviteAllWrite, setInviteAllWrite] = useState(false);
|
const [inviteAllWrite, setInviteAllWrite] = useState(false);
|
||||||
const [inviteAccess, setInviteAccess] = useState<BoardAccessState>(
|
const [inviteAccess, setInviteAccess] =
|
||||||
defaultBoardAccess,
|
useState<BoardAccessState>(defaultBoardAccess);
|
||||||
);
|
|
||||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -331,9 +332,8 @@ export default function OrganizationPage() {
|
|||||||
const [accessAllRead, setAccessAllRead] = useState(false);
|
const [accessAllRead, setAccessAllRead] = useState(false);
|
||||||
const [accessAllWrite, setAccessAllWrite] = useState(false);
|
const [accessAllWrite, setAccessAllWrite] = useState(false);
|
||||||
const [accessRole, setAccessRole] = useState("member");
|
const [accessRole, setAccessRole] = useState("member");
|
||||||
const [accessMap, setAccessMap] = useState<BoardAccessState>(
|
const [accessMap, setAccessMap] =
|
||||||
defaultBoardAccess,
|
useState<BoardAccessState>(defaultBoardAccess);
|
||||||
);
|
|
||||||
const [accessError, setAccessError] = useState<string | null>(null);
|
const [accessError, setAccessError] = useState<string | null>(null);
|
||||||
|
|
||||||
const orgQuery = useGetMyOrgApiV1OrganizationsMeGet<
|
const orgQuery = useGetMyOrgApiV1OrganizationsMeGet<
|
||||||
@@ -426,32 +426,33 @@ export default function OrganizationPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createInviteMutation = useCreateOrgInviteApiV1OrganizationsMeInvitesPost<
|
const createInviteMutation =
|
||||||
ApiError
|
useCreateOrgInviteApiV1OrganizationsMeInvitesPost<ApiError>({
|
||||||
>({
|
mutation: {
|
||||||
mutation: {
|
onSuccess: (result) => {
|
||||||
onSuccess: (result) => {
|
if (result.status === 200) {
|
||||||
if (result.status === 200) {
|
setInviteEmail("");
|
||||||
setInviteEmail("");
|
setInviteRole("member");
|
||||||
setInviteRole("member");
|
setInviteScope("all");
|
||||||
setInviteScope("all");
|
setInviteAllRead(true);
|
||||||
setInviteAllRead(true);
|
setInviteAllWrite(false);
|
||||||
setInviteAllWrite(false);
|
setInviteAccess(defaultBoardAccess);
|
||||||
setInviteAccess(defaultBoardAccess);
|
setInviteError(null);
|
||||||
setInviteError(null);
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({
|
queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey(
|
||||||
queryKey: getListOrgInvitesApiV1OrganizationsMeInvitesGetQueryKey({
|
{
|
||||||
limit: 200,
|
limit: 200,
|
||||||
}),
|
},
|
||||||
});
|
),
|
||||||
setInviteDialogOpen(false);
|
});
|
||||||
}
|
setInviteDialogOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setInviteError(err.message || "Unable to create invite.");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
});
|
||||||
setInviteError(err.message || "Unable to create invite.");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeInviteMutation =
|
const revokeInviteMutation =
|
||||||
useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete<ApiError>({
|
useRevokeOrgInviteApiV1OrganizationsMeInvitesInviteIdDelete<ApiError>({
|
||||||
@@ -472,9 +473,11 @@ export default function OrganizationPage() {
|
|||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey({
|
queryKey: getListOrgMembersApiV1OrganizationsMeMembersGetQueryKey(
|
||||||
limit: 200,
|
{
|
||||||
}),
|
limit: 200,
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
if (activeMemberId) {
|
if (activeMemberId) {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -535,9 +538,7 @@ export default function OrganizationPage() {
|
|||||||
}, [inviteDialogOpen]);
|
}, [inviteDialogOpen]);
|
||||||
|
|
||||||
const orgName =
|
const orgName =
|
||||||
orgQuery.data?.status === 200
|
orgQuery.data?.status === 200 ? orgQuery.data.data.name : "Organization";
|
||||||
? orgQuery.data.data.name
|
|
||||||
: "Organization";
|
|
||||||
|
|
||||||
const handleInviteSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
const handleInviteSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -710,7 +711,10 @@ export default function OrganizationPage() {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
|
||||||
Organization
|
Organization
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant="outline" className="flex items-center gap-2">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
<Building2 className="h-3.5 w-3.5" />
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
{orgName}
|
{orgName}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import { type getMyMembershipApiV1OrganizationsMeMemberGetResponse, useGetMyMembershipApiV1OrganizationsMeMemberGet } from "@/api/generated/organizations/organizations";
|
import {
|
||||||
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
|
} from "@/api/generated/organizations/organizations";
|
||||||
import {
|
import {
|
||||||
type healthzHealthzGetResponse,
|
type healthzHealthzGetResponse,
|
||||||
useHealthzHealthzGet,
|
useHealthzHealthzGet,
|
||||||
@@ -65,7 +68,7 @@ export function DashboardSidebar() {
|
|||||||
? "System status unavailable"
|
? "System status unavailable"
|
||||||
: "System degraded";
|
: "System degraded";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
<aside className="flex h-full w-64 flex-col border-r border-slate-200 bg-white">
|
||||||
<div className="flex-1 px-3 py-4">
|
<div className="flex-1 px-3 py-4">
|
||||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export function OrgSwitcher() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createOrgMutation = useCreateOrganizationApiV1OrganizationsPost<ApiError>(
|
const createOrgMutation =
|
||||||
{
|
useCreateOrganizationApiV1OrganizationsPost<ApiError>({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setOrgName("");
|
setOrgName("");
|
||||||
@@ -110,8 +110,7 @@ export function OrgSwitcher() {
|
|||||||
setOrgError(err.message || "Unable to create organization.");
|
setOrgError(err.message || "Unable to create organization.");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const handleOrgChange = (value: string) => {
|
const handleOrgChange = (value: string) => {
|
||||||
if (value === "__create__") {
|
if (value === "__create__") {
|
||||||
|
|||||||
Reference in New Issue
Block a user