Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ENV.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ These variables are for local repo tooling and development workflows. They are n
| `SERVERBEE_RATE_LIMIT__LOGIN_MAX` | `rate_limit.login_max` | u32 | `5` | Maximum login attempts per IP within 15-minute window |
| `SERVERBEE_RATE_LIMIT__REGISTER_MAX` | `rate_limit.register_max` | u32 | `3` | Maximum agent registrations per IP within 15-minute window |
| `SERVERBEE_UPGRADE__RELEASE_BASE_URL` | `upgrade.release_base_url` | string | `https://github.com/ZingerLittleBee/ServerBee/releases` | Base URL for agent upgrade release assets |
| `SERVERBEE_UPGRADE__LATEST_VERSION_URL` | `upgrade.latest_version_url` | string | `""` | Optional custom URL for latest version API. If empty, uses GitHub API |
| `SERVERBEE_FILE__MAX_UPLOAD_SIZE` | `file.max_upload_size` | u64 | `104857600` (100 MB) | Maximum file upload size in bytes |

## Agent
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/content/docs/cn/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ ServerBee 使用 [figment](https://github.com/SergioBenitez/Figment) 库加载
| `SERVERBEE_RATE_LIMIT__LOGIN_MAX` | `5` | 15 分钟内每 IP 最大登录尝试次数 |
| `SERVERBEE_RATE_LIMIT__REGISTER_MAX` | `3` | 15 分钟内每 IP 最大 Agent 注册次数 |
| `SERVERBEE_UPGRADE__RELEASE_BASE_URL` | `https://github.com/ZingerLittleBee/ServerBee/releases` | Agent 升级 Release 资产的基础 URL |
| `SERVERBEE_UPGRADE__LATEST_VERSION_URL` | `""` | 可选的自定义最新版本 API URL,留空则使用 GitHub API |
| `SERVERBEE_FILE__MAX_UPLOAD_SIZE` | `104857600` | 文件上传最大大小(字节),默认 100 MB |

### Agent 环境变量
Expand Down Expand Up @@ -309,6 +310,12 @@ file = ""
# 默认: "https://github.com/ZingerLittleBee/ServerBee/releases"
release_base_url = "https://github.com/ZingerLittleBee/ServerBee/releases"

# 可选的自定义最新版本 API URL
# 留空则使用 GitHub API 查询最新版本
# 用于自定义版本发布渠道或私有镜像源
# 默认: ""
latest_version_url = ""

# --- 文件上传配置 ---
[file]
# 文件上传最大大小(字节)
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/en/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ These variables are intentionally scoped to local tooling. `ALLOW_WRITES` is not
| `SERVERBEE_RATE_LIMIT__LOGIN_MAX` | `5` | Max login attempts per IP within 15-minute window |
| `SERVERBEE_RATE_LIMIT__REGISTER_MAX` | `3` | Max agent registrations per IP within 15-minute window |
| `SERVERBEE_UPGRADE__RELEASE_BASE_URL` | `https://github.com/ZingerLittleBee/ServerBee/releases` | Base URL for agent upgrade release assets |
| `SERVERBEE_UPGRADE__LATEST_VERSION_URL` | `""` | Optional custom URL for latest version API. If empty, uses GitHub API |
| `SERVERBEE_FILE__MAX_UPLOAD_SIZE` | `104857600` | Maximum file upload size in bytes (default 100 MB) |

### Agent Environment Variables
Expand Down Expand Up @@ -267,6 +268,7 @@ The log level can also be set via the `RUST_LOG` environment variable, which tak
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `release_base_url` | string | `"https://github.com/ZingerLittleBee/ServerBee/releases"` | Base URL for agent upgrade release assets. The server appends `/download/v{version}/` to construct the asset download URL |
| `latest_version_url` | string | `""` | Optional custom URL for latest version API. If empty, the server queries GitHub API to determine the latest version. Use this to override with a custom version endpoint |

### `[file]` -- File Upload (Server-side)

Expand Down
45 changes: 43 additions & 2 deletions apps/web/src/components/dashboard/widget-config-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,35 @@ import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { WidgetConfigDialog } from './widget-config-dialog'

const translations: Record<string, string> = {
'dialogs.widgetConfig.configureTitle': 'Configure Widget',
'dialogs.widgetConfig.editTitle': 'Edit Widget',
'dialogs.widgetConfig.labels.titleOptional': 'Title (optional)',
'dialogs.widgetConfig.placeholders.widgetTitle': 'Widget title',
'dialogs.widgetConfig.messages.noConfigNeeded': 'No additional configuration needed.',
'widgets.common.labels.server': 'Server',
'widgets.common.labels.servers': 'Servers',
'widgets.common.labels.metric': 'Metric',
'widgets.common.labels.timeRange': 'Time Range',
'widgets.common.labels.days': 'Days',
'widgets.common.labels.markdownContent': 'Markdown Content',
'widgets.common.placeholders.writeMarkdown': 'Write markdown here...',
'common.metrics.serverCount': 'Server Count',
'common.metrics.avgCpu': 'Average CPU',
'common.metrics.avgMemory': 'Average Memory',
'common.metrics.health': 'Health',
'common.metrics.cpu': 'CPU',
'common.metrics.memory': 'Memory',
'common.timeRange.1hour': '1 hour',
'common.timeRange.24hours': '24 hours',
'common.timeRange.30days': '30 days',
'common.timeRange.60days': '60 days',
'common.timeRange.90days': '90 days'
}

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key
t: (key: string, fallback?: string) => translations[key] ?? fallback ?? key
})
}))

Expand Down Expand Up @@ -45,7 +71,22 @@ vi.mock('@/components/ui/button', () => ({
}))

vi.mock('@/components/ui/checkbox', () => ({
Checkbox: (props: Record<string, unknown>) => <input data-testid="checkbox" type="checkbox" {...props} />
Checkbox: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
} & Record<string, unknown>) => (
<input
checked={checked}
data-testid="checkbox"
onChange={() => onCheckedChange?.(!checked)}
type="checkbox"
{...props}
/>
)
Comment on lines +74 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether tri-state checkbox semantics are used in the repo.
rg -n -C3 "onCheckedChange|indeterminate|checked\s*:\s*'indeterminate'" --type=ts --type=tsx

Repository: ZingerLittleBee/ServerBee

Length of output: 96


🌐 Web query:

For the @base-ui/react/checkboxversion used by this repository, what are the exact types and semantics for Checkbox.RootcheckedandonCheckedChange?

💡 Result:

For @base-ui/react/checkbox Checkbox.Root: checked: boolean | undefined (controlled checked state; use defaultChecked for uncontrolled) onCheckedChange: ((checked: boolean, eventDetails: CheckboxRootChangeEventDetails) => void) | undefined Where CheckboxRootChangeEventDetails = BaseUIChangeEventDetails with reason: 'none'. Note: The query mentions "version used by this repository" but no specific repo provided; types from @base-ui/react@1.1.0 and current docs match. Base UI (MUI) follows similar patterns to Radix but uses boolean (not 'indeterminate') for checked prop.

Citations:


🏁 Script executed:

#!/bin/bash
# Retry rg search with correct file types (use ts instead of tsx, or use generic search)
rg -n "onCheckedChange|indeterminate" --type ts apps/web/src

Repository: ZingerLittleBee/ServerBee

Length of output: 3601


🏁 Script executed:

#!/bin/bash
# Check package.json for `@base-ui/react` version
cat apps/web/package.json | grep -A2 -B2 "@base-ui/react"

Repository: ZingerLittleBee/ServerBee

Length of output: 207


🏁 Script executed:

#!/bin/bash
# Find and inspect the production checkbox component
fd -e ts -e tsx "checkbox" apps/web/src/components/ui/ 2>/dev/null | head -5

Repository: ZingerLittleBee/ServerBee

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Look at the actual checkbox implementation
find apps/web/src/components/ui -name "*checkbox*" -type f

Repository: ZingerLittleBee/ServerBee

Length of output: 108


🏁 Script executed:

#!/bin/bash
# Inspect the production checkbox component
cat -n apps/web/src/components/ui/checkbox.tsx

Repository: ZingerLittleBee/ServerBee

Length of output: 1577


Fix the checkbox mock to align with actual input state transitions.

On Line 85, using !checked to toggle can diverge from the real HTML input's state. Use event.currentTarget.checked to ensure the mock's behavior matches the actual Checkbox component semantics.

Proposed fix
     <input
       checked={checked}
       data-testid="checkbox"
-      onChange={() => onCheckedChange?.(!checked)}
+      onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
       type="checkbox"
       {...props}
     />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Checkbox: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
} & Record<string, unknown>) => (
<input
checked={checked}
data-testid="checkbox"
onChange={() => onCheckedChange?.(!checked)}
type="checkbox"
{...props}
/>
)
Checkbox: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
} & Record<string, unknown>) => (
<input
checked={checked}
data-testid="checkbox"
onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
type="checkbox"
{...props}
/>
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/dashboard/widget-config-dialog.test.tsx` around lines
74 - 89, The Checkbox mock's onChange currently toggles using !checked which can
drift from real DOM behavior; update the mock component (Checkbox in the test
file) to read the new checked state from the event (use
event.currentTarget.checked) inside the onChange handler and pass that value to
onCheckedChange so the test simulates actual input state transitions
consistently.

}))

vi.mock('@/lib/markdown', () => ({
Expand Down
288 changes: 288 additions & 0 deletions apps/web/src/components/server/agent-version-section.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentVersionSection } from './agent-version-section'

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key
})
}))

const mockTriggerUpgrade = vi.fn()
const mockUseUpgradeJob = vi.fn()
vi.mock('@/hooks/use-upgrade-job', () => ({
useUpgradeJob: (serverId: string) => mockUseUpgradeJob(serverId)
}))

const mockUseAuth = vi.fn()
vi.mock('@/hooks/use-auth', () => ({
useAuth: () => mockUseAuth()
}))

const mockGetEffectiveCapabilityEnabled = vi.fn()
vi.mock('@/lib/capabilities', () => ({
CAP_UPGRADE: 4,
getEffectiveCapabilityEnabled: (...args: unknown[]) => mockGetEffectiveCapabilityEnabled(...args)
}))
const UPGRADE_LATEST_PATTERN = /upgrade_latest_version/
const UPGRADE_ERROR_WITH_BACKUP_PATTERN = /upgrade_error_with_backup/
const UPGRADE_BACKUP_PATH_PATTERN = /upgrade_backup_path/

describe('AgentVersionSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAuth.mockReturnValue({ user: { role: 'admin' } })
mockUseUpgradeJob.mockReturnValue({
job: null,
triggerUpgrade: mockTriggerUpgrade,
isLoading: false
})
mockGetEffectiveCapabilityEnabled.mockReturnValue(true)
})

it('renders current agent version', () => {
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.2.3"
serverId="srv-1"
/>
)
expect(screen.getByText('v1.2.3')).toBeDefined()
})

it('shows unknown version when agentVersion is null', () => {
render(
<AgentVersionSection
agentVersion={null}
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.2.3"
serverId="srv-1"
/>
)
expect(screen.getByText('vunknown')).toBeDefined()
})

it('shows update available badge when versions differ', () => {
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.getByText(UPGRADE_LATEST_PATTERN)).toBeDefined()
})

it('shows upgrade button for admin when update available and capability enabled', () => {
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.getByText('upgrade_start')).toBeDefined()
})

it('does not show upgrade button for non-admin users', () => {
mockUseAuth.mockReturnValue({ user: { role: 'member' } })
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.queryByText('upgrade_start')).toBeNull()
})

it('does not show upgrade button when capability is disabled', () => {
mockGetEffectiveCapabilityEnabled.mockReturnValue(false)
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={0}
effectiveCapabilities={0}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.queryByText('upgrade_start')).toBeNull()
})

it('shows disabled message for admin when capability is disabled', () => {
mockGetEffectiveCapabilityEnabled.mockReturnValue(false)
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={0}
effectiveCapabilities={0}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.getByText('cap_disabled')).toBeDefined()
})

it('triggers upgrade when button clicked', () => {
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
const button = screen.getByText('upgrade_start')
fireEvent.click(button)
expect(mockTriggerUpgrade).toHaveBeenCalledWith('1.3.0')
})

it('shows stepper when upgrade is running', () => {
mockUseUpgradeJob.mockReturnValue({
job: {
backup_path: null,
error: null,
finished_at: null,
job_id: 'job-1',
server_id: 'srv-1',
stage: 'downloading',
started_at: new Date().toISOString(),
status: 'running',
target_version: '1.3.0'
},
triggerUpgrade: mockTriggerUpgrade,
isLoading: false
})
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.getByText('upgrade_in_progress')).toBeDefined()
// The stage name appears in both the badge and stepper, so check for multiple occurrences
const stageElements = screen.getAllByText('upgrade_stage_downloading')
expect(stageElements.length).toBeGreaterThanOrEqual(1)
})

it('shows success state when upgrade succeeded', () => {
mockUseUpgradeJob.mockReturnValue({
job: {
backup_path: null,
error: null,
finished_at: new Date().toISOString(),
job_id: 'job-1',
server_id: 'srv-1',
stage: 'restarting',
started_at: new Date().toISOString(),
status: 'succeeded',
target_version: '1.3.0'
},
triggerUpgrade: mockTriggerUpgrade,
isLoading: false
})
render(
<AgentVersionSection
agentVersion="1.3.0"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.getByText('upgrade_status_succeeded')).toBeDefined()
})

it('shows failed state with error message', () => {
mockUseUpgradeJob.mockReturnValue({
job: {
backup_path: '/tmp/backup',
error: 'Download failed: connection timeout',
finished_at: new Date().toISOString(),
job_id: 'job-1',
server_id: 'srv-1',
stage: 'downloading',
started_at: new Date().toISOString(),
status: 'failed',
target_version: '1.3.0'
},
triggerUpgrade: mockTriggerUpgrade,
isLoading: false
})
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.getByText('upgrade_status_failed')).toBeDefined()
expect(screen.getByText('Download failed: connection timeout')).toBeDefined()
expect(screen.getByText(UPGRADE_ERROR_WITH_BACKUP_PATTERN)).toBeDefined()
})

it('shows timeout state with backup path', () => {
mockUseUpgradeJob.mockReturnValue({
job: {
backup_path: '/opt/serverbee/backups/agent.bak',
error: null,
finished_at: new Date().toISOString(),
job_id: 'job-1',
server_id: 'srv-1',
stage: 'installing',
started_at: new Date().toISOString(),
status: 'timeout',
target_version: '1.3.0'
},
triggerUpgrade: mockTriggerUpgrade,
isLoading: false
})
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
expect(screen.getByText('upgrade_status_timeout')).toBeDefined()
expect(screen.getByText(UPGRADE_BACKUP_PATH_PATTERN)).toBeDefined()
})

it('disables upgrade button while loading', () => {
mockUseUpgradeJob.mockReturnValue({
job: null,
triggerUpgrade: mockTriggerUpgrade,
isLoading: true
})
render(
<AgentVersionSection
agentVersion="1.2.3"
configuredCapabilities={255}
effectiveCapabilities={255}
latestVersion="1.3.0"
serverId="srv-1"
/>
)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
})
Loading
Loading