Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
127 changes: 127 additions & 0 deletions console-ui/e2e/status-bar.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { expect, test } from '@playwright/test'

const VIEWPORTS = [
{ width: 320, height: 568 },
{ width: 360, height: 800 },
{ width: 390, height: 844 },
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
{ width: 1440, height: 900 },
]

const API_RESPONSES = {
'/api/v1/device': {
deviceName: 'edge-link-01',
hostname: 'edge-link-01',
platform: 'linux',
cpuModel: 'Test CPU',
uptimeHuman: '1 小时',
agentVersion: 'test',
},
'/api/v1/metrics': {
cpuUsedPercent: 32,
memoryUsed: 4 * 1024 ** 3,
memoryTotal: 16 * 1024 ** 3,
memoryUsedPercent: 25,
gpuData: [],
diskData: [],
},
'/api/v1/metrics/history': {},
'/api/v1/apps': [],
'/api/v1/alerts': [],
}

async function openConsole(page, viewport) {
await page.setViewportSize(viewport)
await page.addInitScript(() => {
localStorage.setItem('edge_token', 'status-bar-test-token')
localStorage.setItem('edgex-user-prefs', JSON.stringify({ topbar: 'dark', online: true }))
})
await page.route('**/api/v1/**', async (route) => {
const pathname = new URL(route.request().url()).pathname
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(API_RESPONSES[pathname] ?? []),
})
})
await page.goto('/')
await expect(page.locator('.edge-status-bar')).toBeVisible()
}

for (const viewport of VIEWPORTS) {
test(`${viewport.width}px Cloud Link 可见且状态栏无横向溢出`, async ({ page }) => {
const errors = []
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
page.on('pageerror', (error) => errors.push(error.message))

await openConsole(page, viewport)

const link = page.locator('.edge-cloud-link')
const led = link.locator('.edge-cloud-link-led')
const label = link.locator('.edge-cloud-link-label')
await expect(link).toHaveAttribute('aria-label', '云端连接状态:云端在线')
await expect(link).toHaveAttribute('title', '云端连接状态:云端在线')
await expect(link).toHaveAttribute('data-cloud-link-state', 'online')
await expect(led).toBeVisible()
await expect(led).toHaveCSS('background-color', 'rgb(34, 197, 94)')
await expect(led).toHaveCSS('width', '8px')
await expect(led).toHaveCSS('height', '8px')
await expect(link).toHaveCSS('min-width', '44px')
await expect(link).toHaveCSS('min-height', '44px')

if (viewport.width < 1024) {
await expect(label).toBeHidden()
} else {
await expect(label).toBeVisible()
await expect(label).toHaveText('云端在线')
}

const overflow = await page.evaluate(() => {
const statusBar = document.querySelector('.edge-status-bar')
return {
documentClientWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
statusClientWidth: statusBar.clientWidth,
statusScrollWidth: statusBar.scrollWidth,
}
})
expect(overflow.documentScrollWidth).toBe(overflow.documentClientWidth)
expect(overflow.statusScrollWidth).toBe(overflow.statusClientWidth)
expect(errors).toEqual([])
})
}

test('Cloud Link 四态颜色与连接中动画语义', async ({ page }) => {
await openConsole(page, { width: 1440, height: 900 })
const link = page.locator('.edge-cloud-link')
const led = link.locator('.edge-cloud-link-led')
const states = [
['online', 'rgb(34, 197, 94)', false],
['connecting', 'rgb(245, 158, 11)', true],
['offline', 'rgb(220, 107, 103)', false],
['unknown', 'rgb(148, 163, 184)', false],
]

for (const [state, color, animated] of states) {
await link.evaluate((element, nextState) => {
element.className = `edge-cloud-link edge-cloud-link--${nextState}`
element.dataset.cloudLinkState = nextState
}, state)
await expect(led).toHaveCSS('background-color', color)
const animationName = await led.evaluate((element) => getComputedStyle(element).animationName)
expect(animationName === 'edgeCloudLinkConnecting').toBe(animated)
}
})

test('连接中动画尊重 prefers-reduced-motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await openConsole(page, { width: 1440, height: 900 })
const link = page.locator('.edge-cloud-link')
await link.evaluate((element) => {
element.className = 'edge-cloud-link edge-cloud-link--connecting'
})
await expect(link.locator('.edge-cloud-link-led')).toHaveCSS('animation-name', 'none')
})
1 change: 1 addition & 0 deletions console-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Edge-X · 本地控制台</title>
</head>
<body>
Expand Down
64 changes: 64 additions & 0 deletions console-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion console-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test:status-bar": "playwright test e2e/status-bar.spec.js"
},
"dependencies": {
"@xterm/addon-fit": "0.10.0",
Expand All @@ -20,6 +21,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.55.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
Expand Down
18 changes: 18 additions & 0 deletions console-ui/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig } from '@playwright/test'

export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: 'http://127.0.0.1:4173',
browserName: 'chromium',
headless: true,
},
webServer: {
command: 'npm run preview -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: false,
timeout: 30_000,
},
})
20 changes: 16 additions & 4 deletions console-ui/src/components/AppShell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import DiskManager from '../pages/DiskManager'
import NetworkConnections from '../pages/NetworkConnections'
import MonitoringApp from '../pages/Monitoring'
import AIActivity from '../pages/AIActivity'
import { useViewportEnvironment } from '../hooks/useViewportEnvironment'

// Reusable face header
function FaceHeader({ accent = T.blue, title, subtitle, version, kb, onMgmt, extra, errorMode }) {
Expand Down Expand Up @@ -46,8 +47,10 @@ function FaceHeader({ accent = T.blue, title, subtitle, version, kb, onMgmt, ext
// IframeFace — generic iframe wrapper for installed apps
// ═══════════════════════════════════════════════════════════════
function IframeFace({ app, onMgmt }) {
// Resolve the first HostPort from the app's port mappings
const hostPort = app?.ports?.find(p => p.hostPort > 0)?.hostPort
const { compactWindow } = useViewportEnvironment();
// Browser-reachable mappings expose nodePort; containerPort is only a last-resort legacy fallback.
const hostPort = app?.ports?.find(p => p.nodePort > 0)?.nodePort
|| app?.ports?.find(p => p.hostPort > 0)?.hostPort
|| app?.ports?.find(p => p.containerPort > 0)?.containerPort;

if (!hostPort) {
Expand All @@ -66,10 +69,19 @@ function IframeFace({ app, onMgmt }) {

const src = `http://${window.location.hostname}:${hostPort}`;
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div className="edge-iframe-face" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<FaceHeader accent={T.blue} title={app?.name || 'App'} version={app?.version}
subtitle={src} onMgmt={onMgmt}
errorMode={app?.state === 'error'}/>
errorMode={app?.state === 'error'}
extra={<a href={src} target="_blank" rel="noreferrer" className="edge-iframe-open" title="在新窗口打开">
<Icon name="external" size={13} stroke={1.9}/>新窗口
</a>}/>
{compactWindow && (
<div className="edge-iframe-notice">
<Icon name="info" size={14} stroke={1.8}/>
<span>移动设备上的嵌入体验取决于目标应用;如遇到空白、登录或触控问题,请使用始终可用的“新窗口”。</span>
</div>
)}
<iframe
src={src}
style={{ flex: 1, width: '100%', border: 'none' }}
Expand Down
Loading