mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 06:16:09 +00:00
fix(cloud): surface runtime and workspace plan status
This commit is contained in:
@@ -27,6 +27,18 @@ class BoxRouterGroup(group.RouterGroup):
|
|||||||
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
|
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
|
||||||
return self.success(data=status)
|
return self.success(data=status)
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/runtime-status',
|
||||||
|
methods=['GET'],
|
||||||
|
auth_type=group.AuthType.USER_TOKEN,
|
||||||
|
permission=Permission.RESOURCE_VIEW,
|
||||||
|
)
|
||||||
|
async def _(request_context: RequestContext) -> str:
|
||||||
|
del request_context
|
||||||
|
status = await self.ap.box_service.get_backend_status()
|
||||||
|
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
|
||||||
|
return self.success(data=status)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/sessions',
|
'/sessions',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
|
|||||||
@@ -70,19 +70,26 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
|
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
|
||||||
return self.success(
|
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||||
data={
|
workspaces: list[dict[str, typing.Any]] = []
|
||||||
'workspaces': [
|
for access in accesses:
|
||||||
{
|
plan_name: str | None = None
|
||||||
'workspace': _workspace_payload(access.workspace),
|
if access.workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None:
|
||||||
'membership': _membership_payload(access.membership, email=account.user),
|
entitlement = await resolver.resolve(
|
||||||
'permissions': sorted(permissions_for_role(access.membership.role)),
|
access.workspace.uuid,
|
||||||
'placement_generation': access.execution.placement_generation,
|
minimum_revision=access.membership.projection_revision,
|
||||||
}
|
)
|
||||||
for access in accesses
|
plan_name = entitlement.plan_name
|
||||||
]
|
workspaces.append(
|
||||||
}
|
{
|
||||||
)
|
'workspace': _workspace_payload(access.workspace),
|
||||||
|
'membership': _membership_payload(access.membership, email=account.user),
|
||||||
|
'permissions': sorted(permissions_for_role(access.membership.role)),
|
||||||
|
'placement_generation': access.execution.placement_generation,
|
||||||
|
'plan_name': plan_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.success(data={'workspaces': workspaces})
|
||||||
|
|
||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
|
@self.route('', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
|
||||||
async def _(account) -> typing.Any:
|
async def _(account) -> typing.Any:
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ async def box_security_api():
|
|||||||
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
|
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
|
||||||
)
|
)
|
||||||
application.box_service.get_status = AsyncMock(return_value={'enabled': True})
|
application.box_service.get_status = AsyncMock(return_value={'enabled': True})
|
||||||
|
application.box_service.get_backend_status = AsyncMock(
|
||||||
|
return_value={'available': True, 'enabled': True, 'backend': {'name': 'nsjail'}}
|
||||||
|
)
|
||||||
application.box_service.get_sessions = AsyncMock(return_value=[{'session_id': 'private-session'}])
|
application.box_service.get_sessions = AsyncMock(return_value=[{'session_id': 'private-session'}])
|
||||||
application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}])
|
application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}])
|
||||||
application.box_service.managed_admission_required = False
|
application.box_service.managed_admission_required = False
|
||||||
@@ -99,3 +102,18 @@ async def test_box_status_returns_explicit_403_when_workspace_has_no_managed_san
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
payload = await response.get_json()
|
payload = await response.get_json()
|
||||||
assert payload['code'] == 'managed_sandbox_unavailable'
|
assert payload['code'] == 'managed_sandbox_unavailable'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runtime_status_reports_connector_health_without_consuming_workspace_entitlement(box_security_api):
|
||||||
|
application, client = box_security_api
|
||||||
|
application.box_service.get_status.side_effect = EntitlementUnavailableError(
|
||||||
|
'Workspace entitlement does not grant managed_sandbox'
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.get('/api/v1/box/runtime-status', headers=_headers('viewer-token'))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data']['available'] is True
|
||||||
|
application.box_service.get_backend_status.assert_awaited_once()
|
||||||
|
application.box_service.get_status.assert_not_awaited()
|
||||||
|
|||||||
@@ -484,6 +484,12 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_
|
|||||||
)
|
)
|
||||||
|
|
||||||
application.entitlement_resolver = PlanResolver()
|
application.entitlement_resolver = PlanResolver()
|
||||||
|
bootstrap_with_plans = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
|
||||||
|
bootstrap_by_uuid = {
|
||||||
|
item['workspace']['uuid']: item for item in (await bootstrap_with_plans.get_json())['data']['workspaces']
|
||||||
|
}
|
||||||
|
assert bootstrap_by_uuid[cloud_workspace_uuid]['plan_name'] == 'free'
|
||||||
|
assert bootstrap_by_uuid[singleton_uuid]['plan_name'] is None
|
||||||
current_response = await client.get(
|
current_response = await client.get(
|
||||||
'/api/v1/workspaces/current',
|
'/api/v1/workspaces/current',
|
||||||
headers=_auth(owner_token, cloud_workspace_uuid),
|
headers=_auth(owner_token, cloud_workspace_uuid),
|
||||||
|
|||||||
@@ -1916,7 +1916,7 @@ export default function HomeSidebar({
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
<WorkspaceSwitcher className="w-1/2 max-w-36 group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation items grouped by section */}
|
{/* Navigation items grouped by section */}
|
||||||
|
|||||||
@@ -225,22 +225,13 @@ export default function WorkspaceSettingsPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isCloudProjection && workspaceInfo && (
|
{isCloudProjection && workspaceInfo && (
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm">
|
||||||
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
|
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
|
||||||
{t('workspace.upgradePlan')}
|
{t('workspace.upgradePlan')}
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canManageCloudMembers && (
|
|
||||||
<Button asChild size="sm" variant="outline">
|
|
||||||
<a href={cloudMembersURL} target="_blank" rel="noopener noreferrer">
|
|
||||||
<UserPlus />
|
|
||||||
{t('workspace.inviteMember')}
|
|
||||||
<ExternalLink className="size-3.5" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</PanelToolbar>
|
</PanelToolbar>
|
||||||
|
|
||||||
<PanelBody className="space-y-6">
|
<PanelBody className="space-y-6">
|
||||||
@@ -315,7 +306,24 @@ export default function WorkspaceSettingsPanel({
|
|||||||
|
|
||||||
{canViewMembers && (
|
{canViewMembers && (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">{t('workspace.members')}</h3>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
{t('workspace.members')}
|
||||||
|
</h3>
|
||||||
|
{canManageCloudMembers && (
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a
|
||||||
|
href={cloudMembersURL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
{t('workspace.inviteMember')}
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
const isSelf =
|
const isSelf =
|
||||||
|
|||||||
@@ -39,17 +39,14 @@ export default function WorkspaceSwitcher({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn('h-9 min-w-0 justify-start px-2.5 text-sm', className)}
|
||||||
'h-11 min-w-52 max-w-72 justify-start px-3 text-[15px]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
aria-label={t('workspace.switchWorkspace')}
|
aria-label={t('workspace.switchWorkspace')}
|
||||||
>
|
>
|
||||||
<Building2 className="size-4 shrink-0" />
|
<Building2 className="size-4 shrink-0" />
|
||||||
<span className="truncate">{currentWorkspace.workspace.name}</span>
|
<span className="truncate">{currentWorkspace.workspace.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-80 p-2">
|
<DropdownMenuContent align="start" className="w-64 p-1.5">
|
||||||
<DropdownMenuLabel className="px-3 py-2 text-sm">
|
<DropdownMenuLabel className="px-3 py-2 text-sm">
|
||||||
{t('workspace.switchWorkspace')}
|
{t('workspace.switchWorkspace')}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
@@ -59,14 +56,14 @@ export default function WorkspaceSwitcher({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={entry.workspace.uuid}
|
key={entry.workspace.uuid}
|
||||||
className="min-h-14 gap-3 px-3 py-2"
|
className="min-h-11 gap-2 px-2 py-1.5"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selected)
|
if (!selected)
|
||||||
void switchWorkspaceAndReload(entry.workspace.uuid);
|
void switchWorkspaceAndReload(entry.workspace.uuid);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Building2 className="size-5" />
|
<Building2 className="size-4 shrink-0" />
|
||||||
<span className="min-w-0 flex-1 truncate font-medium">
|
<span className="max-w-[7rem] min-w-0 flex-1 truncate font-medium">
|
||||||
{entry.workspace.name}
|
{entry.workspace.name}
|
||||||
</span>
|
</span>
|
||||||
{entry.workspace.source === 'cloud_projection' && (
|
{entry.workspace.source === 'cloud_projection' && (
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function SystemStatusCard({
|
|||||||
try {
|
try {
|
||||||
const [plugin, box] = await Promise.all([
|
const [plugin, box] = await Promise.all([
|
||||||
httpClient.getPluginSystemStatus().catch(() => null),
|
httpClient.getPluginSystemStatus().catch(() => null),
|
||||||
httpClient.getBoxStatus().catch(() => null),
|
httpClient.getBoxRuntimeStatus().catch(() => null),
|
||||||
]);
|
]);
|
||||||
const sessions = box?.hidden
|
const sessions = box?.hidden
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -1067,6 +1067,10 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get('/api/v1/plugins/debug-info');
|
return this.get('/api/v1/plugins/debug-info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getBoxRuntimeStatus(): Promise<ApiRespBoxStatus> {
|
||||||
|
return this.get('/api/v1/box/runtime-status');
|
||||||
|
}
|
||||||
|
|
||||||
public getBoxStatus(): Promise<ApiRespBoxStatus> {
|
public getBoxStatus(): Promise<ApiRespBoxStatus> {
|
||||||
return this.get('/api/v1/box/status');
|
return this.get('/api/v1/box/status');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,9 +83,28 @@ test('moves Cloud plan upgrades into Workspace Settings', () => {
|
|||||||
assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/);
|
assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/);
|
||||||
assert.match(workspaceSettingsPanelSource, /cloudPortalURL/);
|
assert.match(workspaceSettingsPanelSource, /cloudPortalURL/);
|
||||||
assert.match(workspaceSettingsPanelSource, /step=plan/);
|
assert.match(workspaceSettingsPanelSource, /step=plan/);
|
||||||
|
const toolbarEnd = workspaceSettingsPanelSource.indexOf('</PanelToolbar>');
|
||||||
|
const membersHeading =
|
||||||
|
workspaceSettingsPanelSource.indexOf('workspace.members');
|
||||||
|
const cloudInvite = workspaceSettingsPanelSource.indexOf(
|
||||||
|
'href={cloudMembersURL}',
|
||||||
|
);
|
||||||
|
assert.ok(toolbarEnd < membersHeading && membersHeading < cloudInvite);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses a larger workspace trigger and menu surface', () => {
|
test('keeps the workspace trigger compact and truncates long names', () => {
|
||||||
assert.match(workspaceSwitcherSource, /h-11/);
|
assert.match(homeSidebarSource, /<WorkspaceSwitcher className="w-1\/2/);
|
||||||
assert.match(workspaceSwitcherSource, /min-w-80/);
|
assert.match(workspaceSwitcherSource, /h-9/);
|
||||||
|
assert.match(workspaceSwitcherSource, /w-64/);
|
||||||
|
assert.doesNotMatch(workspaceSwitcherSource, /min-w-80/);
|
||||||
|
assert.match(workspaceSwitcherSource, /max-w-\[7rem\][^>]*truncate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses an infrastructure-level Box health endpoint in monitoring', () => {
|
||||||
|
const statusCardSource = readSource(
|
||||||
|
'src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx',
|
||||||
|
);
|
||||||
|
const backendClientSource = readSource('src/app/infra/http/BackendClient.ts');
|
||||||
|
assert.match(backendClientSource, /getBoxRuntimeStatus/);
|
||||||
|
assert.match(statusCardSource, /getBoxRuntimeStatus/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user