Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bb49b83e5 | ||
|
|
8070fdea7c | ||
|
|
7830ac5e0d | ||
|
|
fdccca5378 | ||
|
|
0d4215678a | ||
|
|
28690b2a7a | ||
|
|
5eb807c572 | ||
|
|
f359a2ba3d | ||
|
|
384ee97643 | ||
|
|
a12b374fb2 | ||
|
|
433d582da6 | ||
|
|
2ffc268b44 | ||
|
|
99ae66315b | ||
|
|
26de5ec58f | ||
|
|
d26dc6a8a5 | ||
|
|
c0b1cbdc5b | ||
|
|
d101d883a9 | ||
|
|
2a020c1e15 | ||
|
|
19303eefad |
@@ -15,7 +15,21 @@
|
|||||||
"Bash(echo \"EXIT:$?\")",
|
"Bash(echo \"EXIT:$?\")",
|
||||||
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.4\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
"Bash(echo \"EXIT_CODE=$?\")",
|
"Bash(echo \"EXIT_CODE=$?\")",
|
||||||
"Bash(echo \"EXIT=$?\")"
|
"Bash(echo \"EXIT=$?\")",
|
||||||
|
"mcp__gitea__actions_config_read",
|
||||||
|
"mcp__gitea__search_repos",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(node -e \"console.log\\(JSON.parse\\(require\\(''fs''\\).readFileSync\\(''package.json'',''utf8''\\)\\).devDependencies[''vite-plugin-dts'']\\)\")",
|
||||||
|
"Bash(npx vite:*)",
|
||||||
|
"Bash(cd:*)",
|
||||||
|
"mcp__gitea__actions_run_read",
|
||||||
|
"mcp__gitea__get_file_contents",
|
||||||
|
"WebFetch(domain:ui.shadcn.com)",
|
||||||
|
"Bash(bash \"C:\\\\Users\\\\Hendrik\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\superpowers\\\\5.0.5\\\\skills\\\\brainstorming\\\\scripts\\\\start-server.sh\" --project-dir \"C:\\\\Users\\\\Hendrik\\\\Documents\\\\projects\\\\design-system\")",
|
||||||
|
"Bash(bash \"C:/Users/Hendrik/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.5/skills/brainstorming/scripts/stop-server.sh\" \"C:/Users/Hendrik/Documents/projects/design-system/.superpowers/brainstorm/470-1774344716\")",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(xargs cat:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
.gitea/workflows/sonarqube.yml
Normal file
62
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: SonarQube Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonarqube:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: npx vitest run --exclude 'e2e/**' --coverage --coverage.reporter=lcov
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Install sonar-scanner
|
||||||
|
run: |
|
||||||
|
SONAR_SCANNER_VERSION=6.2.1.4610
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||||
|
PLATFORM="linux-aarch64"
|
||||||
|
else
|
||||||
|
PLATFORM="linux-x64"
|
||||||
|
fi
|
||||||
|
curl -sSLo sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-${PLATFORM}.zip"
|
||||||
|
unzip -q sonar-scanner.zip
|
||||||
|
echo "$PWD/sonar-scanner-${SONAR_SCANNER_VERSION}-${PLATFORM}/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run SonarQube analysis
|
||||||
|
env:
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$SONAR_HOST_URL" ] || ! echo "$SONAR_HOST_URL" | grep -qE '^https?://'; then
|
||||||
|
echo "::error::SONAR_HOST_URL is missing or invalid (got: '$SONAR_HOST_URL'). Set it as a repo variable with full URL (e.g. https://sonar.example.com)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sonar-scanner \
|
||||||
|
-Dsonar.host.url="$SONAR_HOST_URL" \
|
||||||
|
-Dsonar.login="$SONAR_TOKEN" \
|
||||||
|
-Dsonar.projectKey=cameleer-design-system \
|
||||||
|
-Dsonar.projectName="Cameleer Design System" \
|
||||||
|
-Dsonar.sources=src/design-system \
|
||||||
|
-Dsonar.tests=src/design-system \
|
||||||
|
-Dsonar.test.inclusions="**/*.test.tsx,**/*.test.ts" \
|
||||||
|
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info \
|
||||||
|
-Dsonar.exclusions="**/node_modules/**,**/dist/**"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
165
e2e/admin.spec.ts
Normal file
165
e2e/admin.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Admin - User Management (/admin/rbac)', () => {
|
||||||
|
test('renders admin tabs and user table', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Admin navigation tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'User Management' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Audit Log' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'OIDC' })).toBeVisible()
|
||||||
|
|
||||||
|
// User Management sub-tabs
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('tab', { name: 'Roles' })).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar breadcrumb
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('User Management')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Users, Groups, and Roles tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Default tab is Users
|
||||||
|
await expect(page.getByRole('tab', { name: 'Users' })).toBeVisible()
|
||||||
|
|
||||||
|
// Switch to Groups tab
|
||||||
|
await page.getByRole('tab', { name: 'Groups' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch to Roles tab
|
||||||
|
await page.getByRole('tab', { name: 'Roles' }).click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
|
||||||
|
// Switch back to Users
|
||||||
|
await page.getByRole('tab', { name: 'Users' }).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating between admin sections via tabs', async ({ page }) => {
|
||||||
|
await page.goto('/admin/rbac')
|
||||||
|
|
||||||
|
// Click Audit Log tab
|
||||||
|
await page.getByRole('tab', { name: 'Audit Log' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/audit/)
|
||||||
|
|
||||||
|
// Click OIDC tab
|
||||||
|
await page.getByRole('tab', { name: 'OIDC' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/oidc/)
|
||||||
|
|
||||||
|
// Back to User Management
|
||||||
|
await page.getByRole('tab', { name: 'User Management' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/admin\/rbac/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - Audit Log (/admin/audit)', () => {
|
||||||
|
test('renders audit table with filters', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Timestamp' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'User' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Category' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Result' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Filter inputs exist
|
||||||
|
await expect(page.getByPlaceholder('Filter by user...')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('Search action or target...')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filtering audit events by search', async ({ page }) => {
|
||||||
|
await page.goto('/admin/audit')
|
||||||
|
|
||||||
|
const searchInput = page.getByPlaceholder('Search action or target...')
|
||||||
|
await searchInput.fill('deploy')
|
||||||
|
|
||||||
|
// Table should update
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
const count = await rows.count()
|
||||||
|
expect(count).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin - OIDC Config (/admin/oidc)', () => {
|
||||||
|
test('renders OIDC form with all fields', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Section headers
|
||||||
|
await expect(page.getByText('Behavior')).toBeVisible()
|
||||||
|
await expect(page.getByText('Provider Settings')).toBeVisible()
|
||||||
|
await expect(page.getByText('Claim Mapping')).toBeVisible()
|
||||||
|
await expect(page.getByText('Default Roles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Danger Zone')).toBeVisible()
|
||||||
|
|
||||||
|
// Form fields by id
|
||||||
|
await expect(page.locator('#issuer')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-id')).toBeVisible()
|
||||||
|
await expect(page.locator('#client-secret')).toBeVisible()
|
||||||
|
await expect(page.locator('#roles-claim')).toBeVisible()
|
||||||
|
await expect(page.locator('#name-claim')).toBeVisible()
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Test Connection' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: /Delete OIDC/i })).toBeVisible()
|
||||||
|
|
||||||
|
// Default roles tags
|
||||||
|
await expect(page.getByText('USER').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('VIEWER').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggling Enabled switch', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// The Toggle's checkbox is visually hidden — click the label wrapper instead
|
||||||
|
const enabledLabel = page.locator('label').filter({ hasText: 'Enabled' })
|
||||||
|
await enabledLabel.click()
|
||||||
|
|
||||||
|
// Should not crash; label still visible
|
||||||
|
await expect(enabledLabel).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('adding and removing a role tag', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
// Add a new role
|
||||||
|
const roleInput = page.getByPlaceholder('Add role...')
|
||||||
|
await roleInput.fill('EDITOR')
|
||||||
|
// Use the Add button next to the input (scoped to same row)
|
||||||
|
await roleInput.press('Enter')
|
||||||
|
|
||||||
|
// New role tag should appear
|
||||||
|
await expect(page.getByText('EDITOR')).toBeVisible()
|
||||||
|
|
||||||
|
// Remove it via aria-label on the tag's remove button
|
||||||
|
await page.getByRole('button', { name: 'Remove EDITOR' }).click()
|
||||||
|
|
||||||
|
// EDITOR tag should be gone
|
||||||
|
await expect(page.getByText('EDITOR')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Save button shows success toast', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
await expect(page.getByText('Settings saved')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Delete button shows confirmation dialog', async ({ page }) => {
|
||||||
|
await page.goto('/admin/oidc')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Delete OIDC/i }).click()
|
||||||
|
|
||||||
|
// Confirmation dialog should appear
|
||||||
|
await expect(page.getByText('Delete OIDC configuration?')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
80
e2e/agents.spec.ts
Normal file
80
e2e/agents.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Agent Health (/agents)', () => {
|
||||||
|
test('renders stat cards and group cards', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Stat strip
|
||||||
|
await expect(page.getByText('Total Agents')).toBeVisible()
|
||||||
|
await expect(page.getByText('Total TPS')).toBeVisible()
|
||||||
|
|
||||||
|
// Group cards for each application
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('notification-hub').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Instance tables have data
|
||||||
|
const instanceRows = page.locator('table tbody tr')
|
||||||
|
expect(await instanceRows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Instance table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Instance' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'State' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Uptime' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'TPS' }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline section
|
||||||
|
await expect(page.getByText('Timeline').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking an instance row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click first instance row
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Detail panel opens — look for detail-specific labels
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Throughput').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detail panel has Performance tab with charts', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// Click an instance to open detail panel
|
||||||
|
const instanceRow = page.locator('table tbody tr').first()
|
||||||
|
await instanceRow.click()
|
||||||
|
|
||||||
|
// Wait for panel to open
|
||||||
|
await expect(page.getByText('Version').first()).toBeVisible()
|
||||||
|
|
||||||
|
// DetailPanel tabs are plain buttons (not role="tab")
|
||||||
|
// Switch to Performance tab
|
||||||
|
const perfTab = page.getByRole('button', { name: 'Performance' })
|
||||||
|
await perfTab.click()
|
||||||
|
|
||||||
|
// Performance charts should render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Error Rate (err/h)').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped agents view', async ({ page }) => {
|
||||||
|
await page.goto('/agents/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb/scope shows app
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Agents')).toBeVisible()
|
||||||
|
|
||||||
|
// Only order-service agents should show
|
||||||
|
await expect(page.getByText('ord-1').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-2').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('ord-3').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dead agent shows alert banner', async ({ page }) => {
|
||||||
|
await page.goto('/agents')
|
||||||
|
|
||||||
|
// notification-hub has a dead instance, should show alert
|
||||||
|
await expect(page.getByText('Single point of failure')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
e2e/dashboard.spec.ts
Normal file
90
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/** Click the 7d time range preset so hardcoded mock data (March 18) is visible. */
|
||||||
|
async function widenTimeRange(page: import('@playwright/test').Page) {
|
||||||
|
await page.getByRole('tab', { name: '7d' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Dashboard (/apps)', () => {
|
||||||
|
test('renders KPI stat cards and exchange table', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// KPI health strip renders
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Application' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchange ID' })).toBeVisible()
|
||||||
|
|
||||||
|
// Table has data rows
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Sidebar renders with app names
|
||||||
|
await expect(page.getByText('order-service').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('payment-svc').first()).toBeVisible()
|
||||||
|
|
||||||
|
// TopBar renders
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('Applications')).toBeVisible()
|
||||||
|
await expect(page.getByText('PRODUCTION')).toBeVisible()
|
||||||
|
|
||||||
|
// Shortcuts bar
|
||||||
|
await expect(page.getByText('Ctrl+K').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a table row opens the detail panel', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Click the first data row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Detail panel should open — look for "Open full details" link
|
||||||
|
await expect(page.getByText('Open full details')).toBeVisible()
|
||||||
|
// Overview section
|
||||||
|
await expect(page.getByText('Correlation').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigating to app-scoped dashboard filters exchanges', async ({ page }) => {
|
||||||
|
await page.goto('/apps/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows app scope
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table should still render
|
||||||
|
await expect(page.getByText('Recent Exchanges')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sidebar navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
|
||||||
|
// Click on an app in the sidebar
|
||||||
|
const sidebarApp = page.getByText('order-service').first()
|
||||||
|
await sidebarApp.click()
|
||||||
|
|
||||||
|
// URL should change to the app scope
|
||||||
|
await expect(page).toHaveURL(/\/apps\/order-service/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inspect button navigates to exchange detail', async ({ page }) => {
|
||||||
|
await page.goto('/apps')
|
||||||
|
await widenTimeRange(page)
|
||||||
|
|
||||||
|
// Wait for table rows to appear
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await expect(firstRow).toBeVisible()
|
||||||
|
|
||||||
|
// Click the inspect button (↗) on first row
|
||||||
|
const inspectBtn = firstRow.locator('button[title="Inspect exchange"]')
|
||||||
|
await inspectBtn.click()
|
||||||
|
|
||||||
|
// Should navigate to exchange detail page
|
||||||
|
await expect(page).toHaveURL(/\/exchanges\//)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
e2e/exchanges.spec.ts
Normal file
60
e2e/exchanges.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Exchange Detail (/exchanges/:id)', () => {
|
||||||
|
test('renders exchange header, timeline, and message panels', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Exchange header — use the one NOT in the breadcrumb
|
||||||
|
await expect(page.getByText('E-2026-03-18-00201').nth(1)).toBeVisible()
|
||||||
|
|
||||||
|
// Header stats
|
||||||
|
await expect(page.getByText('Duration').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Processors').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Processor Timeline section
|
||||||
|
await expect(page.getByText('Processor Timeline').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Timeline/Flow toggle buttons
|
||||||
|
await expect(page.getByRole('button', { name: 'Timeline' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Flow' })).toBeVisible()
|
||||||
|
|
||||||
|
// Message IN panel
|
||||||
|
await expect(page.getByText('Message IN')).toBeVisible()
|
||||||
|
await expect(page.getByText('Headers').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Body').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching between Timeline and Flow view', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Default view is timeline (gantt)
|
||||||
|
const timelineBtn = page.getByRole('button', { name: 'Timeline' })
|
||||||
|
const flowBtn = page.getByRole('button', { name: 'Flow' })
|
||||||
|
|
||||||
|
// Switch to Flow view
|
||||||
|
await flowBtn.click()
|
||||||
|
|
||||||
|
// Flow view should render (RouteFlow component)
|
||||||
|
await expect(flowBtn).toHaveClass(/active|Active/)
|
||||||
|
|
||||||
|
// Switch back to Timeline
|
||||||
|
await timelineBtn.click()
|
||||||
|
await expect(timelineBtn).toHaveClass(/active|Active/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('not-found exchange shows warning', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/nonexistent-id')
|
||||||
|
|
||||||
|
await expect(page.getByText('not found', { exact: false })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('breadcrumb navigation works', async ({ page }) => {
|
||||||
|
await page.goto('/exchanges/E-2026-03-18-00201')
|
||||||
|
|
||||||
|
// Click Applications breadcrumb to go back
|
||||||
|
const appsBreadcrumb = page.getByRole('link', { name: 'Applications' })
|
||||||
|
await appsBreadcrumb.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/apps/)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
e2e/routes.spec.ts
Normal file
63
e2e/routes.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Routes (/routes)', () => {
|
||||||
|
test('renders KPI cards, route table, and charts', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// KPI cards
|
||||||
|
await expect(page.getByText('Total Throughput')).toBeVisible()
|
||||||
|
await expect(page.getByText('System Error Rate')).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency Percentiles')).toBeVisible()
|
||||||
|
await expect(page.getByText('Active Routes')).toBeVisible()
|
||||||
|
await expect(page.getByText('In-Flight Exchanges')).toBeVisible()
|
||||||
|
|
||||||
|
// Route performance table
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Route' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Exchanges' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Success %' })).toBeVisible()
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr')
|
||||||
|
expect(await rows.count()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Charts render
|
||||||
|
await expect(page.getByText('Throughput (msg/s)').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('Latency (ms)')).toBeVisible()
|
||||||
|
await expect(page.getByText('Errors by Route')).toBeVisible()
|
||||||
|
await expect(page.getByText('Message Volume (msg/min)')).toBeVisible()
|
||||||
|
|
||||||
|
// Auto-refresh indicator
|
||||||
|
await expect(page.getByText('Auto-refresh: 30s')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking a route row navigates to route detail', async ({ page }) => {
|
||||||
|
await page.goto('/routes')
|
||||||
|
|
||||||
|
// Click first route row
|
||||||
|
const firstRow = page.locator('table tbody tr').first()
|
||||||
|
await firstRow.click()
|
||||||
|
|
||||||
|
// Should navigate to route detail
|
||||||
|
await expect(page).toHaveURL(/\/routes\/[^/]+\/[^/]+/)
|
||||||
|
|
||||||
|
// Route detail view: processor performance table
|
||||||
|
await expect(page.getByText('Processor Performance')).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Processor' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('columnheader', { name: 'Invocations' })).toBeVisible()
|
||||||
|
|
||||||
|
// Route Flow diagram
|
||||||
|
await expect(page.getByText('Route Flow')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('app-scoped routes view filters data', async ({ page }) => {
|
||||||
|
await page.goto('/routes/order-service')
|
||||||
|
|
||||||
|
// Breadcrumb shows scope
|
||||||
|
await expect(page.getByRole('link', { name: 'Routes' })).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Breadcrumb').getByText('order-service')).toBeVisible()
|
||||||
|
|
||||||
|
// Table still renders
|
||||||
|
await expect(page.getByText('Per-Route Performance')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
737
package-lock.json
generated
737
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "@cameleer/design-system",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
@@ -39,6 +41,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@ampproject/remapping": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -331,6 +347,16 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -773,6 +799,34 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@istanbuljs/schema": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -931,6 +985,17 @@
|
|||||||
"resolve": "~1.22.2"
|
"resolve": "~1.22.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -1697,6 +1762,40 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ampproject/remapping": "^2.3.0",
|
||||||
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
|
"ast-v8-to-istanbul": "^0.3.3",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-lib-source-maps": "^5.0.6",
|
||||||
|
"istanbul-reports": "^3.1.7",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"magicast": "^0.3.5",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"test-exclude": "^7.0.1",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "3.2.4",
|
||||||
|
"vitest": "3.2.4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
@@ -1917,9 +2016,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/language-core/node_modules/brace-expansion": {
|
"node_modules/@vue/language-core/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2025,7 +2124,6 @@
|
|||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -2074,6 +2172,25 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul": {
|
||||||
|
"version": "0.3.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
|
||||||
|
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.31",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"js-tokens": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -2098,9 +2215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2202,6 +2319,26 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compare-versions": {
|
"node_modules/compare-versions": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
|
||||||
@@ -2236,6 +2373,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css.escape": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -2313,6 +2465,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.313",
|
"version": "1.5.313",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
||||||
@@ -2320,6 +2479,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -2461,6 +2627,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "11.3.4",
|
"version": "11.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
||||||
@@ -2511,6 +2694,61 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -2519,9 +2757,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/happy-dom": {
|
"node_modules/happy-dom": {
|
||||||
"version": "20.8.4",
|
"version": "20.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
|
||||||
"integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==",
|
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2569,6 +2807,13 @@
|
|||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-lazy": {
|
"node_modules/import-lazy": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
|
||||||
@@ -2605,6 +2850,106 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-source-maps": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.23",
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"istanbul-lib-coverage": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jju": {
|
"node_modules/jju": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
||||||
@@ -2714,6 +3059,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -2735,6 +3089,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.25.4",
|
||||||
|
"@babel/types": "^7.25.4",
|
||||||
|
"source-map-js": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -2761,6 +3156,16 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mlly": {
|
"node_modules/mlly": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
|
||||||
@@ -2833,6 +3238,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -2840,6 +3252,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
@@ -2847,6 +3269,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -2872,9 +3318,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3194,6 +3640,29 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -3201,6 +3670,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -3252,6 +3734,103 @@
|
|||||||
"node": ">=0.6.19"
|
"node": ">=0.6.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi/node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
@@ -3327,6 +3906,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/test-exclude": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@istanbuljs/schema": "^0.1.2",
|
||||||
|
"glob": "^10.4.1",
|
||||||
|
"minimatch": "^10.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -3672,6 +4266,22 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -3689,6 +4299,107 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
|
|||||||
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -78,17 +78,16 @@ describe('AlertDialog', () => {
|
|||||||
|
|
||||||
it('renders danger variant icon', () => {
|
it('renders danger variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="danger" />)
|
render(<AlertDialog {...defaultProps} variant="danger" />)
|
||||||
// Icon area should be present (aria-hidden)
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders warning variant icon', () => {
|
it('renders warning variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="warning" />)
|
render(<AlertDialog {...defaultProps} variant="warning" />)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders info variant icon', () => {
|
it('renders info variant icon', () => {
|
||||||
render(<AlertDialog {...defaultProps} variant="info" />)
|
render(<AlertDialog {...defaultProps} variant="info" />)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(document.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
import { Modal } from '../Modal/Modal'
|
import { Modal } from '../Modal/Modal'
|
||||||
import { Button } from '../../primitives/Button/Button'
|
import { Button } from '../../primitives/Button/Button'
|
||||||
import styles from './AlertDialog.module.css'
|
import styles from './AlertDialog.module.css'
|
||||||
@@ -16,10 +17,10 @@ interface AlertDialogProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, string> = {
|
const variantIcons: Record<NonNullable<AlertDialogProps['variant']>, React.ReactNode> = {
|
||||||
danger: '✕',
|
danger: <XCircle size={20} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={20} />,
|
||||||
info: 'ℹ',
|
info: <Info size={20} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlertDialog({
|
export function AlertDialog({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CommandPalette.module.css'
|
import styles from './CommandPalette.module.css'
|
||||||
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader'
|
||||||
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock'
|
||||||
@@ -13,12 +14,16 @@ interface CommandPaletteProps {
|
|||||||
data: SearchResult[]
|
data: SearchResult[]
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
onQueryChange?: (query: string) => void
|
onQueryChange?: (query: string) => void
|
||||||
|
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
|
||||||
|
* Useful for applying the query as a full-text search filter. */
|
||||||
|
onSubmit?: (query: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
application: 'Applications',
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
|
attribute: 'Attributes',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
}
|
}
|
||||||
@@ -27,6 +32,7 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
|||||||
'all',
|
'all',
|
||||||
'application',
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
|
'attribute',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'agent',
|
||||||
]
|
]
|
||||||
@@ -61,12 +67,13 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
|||||||
return <>{parts}</>
|
return <>{parts}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange }: CommandPaletteProps) {
|
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
||||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const userNavigated = useRef(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -89,6 +96,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
setQuery('')
|
setQuery('')
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
setExpandedId(null)
|
setExpandedId(null)
|
||||||
|
userNavigated.current = false
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
@@ -147,15 +155,20 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
userNavigated.current = true
|
||||||
setFocusedIdx((i) => Math.max(i - 1, 0))
|
setFocusedIdx((i) => Math.max(i - 1, 0))
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (flatResults[focusedIdx]) {
|
if (!userNavigated.current && onSubmit && query.trim()) {
|
||||||
|
onSubmit(query.trim())
|
||||||
|
onClose()
|
||||||
|
} else if (flatResults[focusedIdx]) {
|
||||||
onSelect(flatResults[focusedIdx])
|
onSelect(flatResults[focusedIdx])
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -187,7 +200,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
>
|
>
|
||||||
{/* Search input area */}
|
{/* Search input area */}
|
||||||
<div className={styles.searchArea}>
|
<div className={styles.searchArea}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">⌕</span>
|
<span className={styles.searchIcon} aria-hidden="true"><Search size={14} /></span>
|
||||||
{scopeFilters.map((sf, i) => (
|
{scopeFilters.map((sf, i) => (
|
||||||
<span key={i} className={styles.scopeTag}>
|
<span key={i} className={styles.scopeTag}>
|
||||||
<span className={styles.scopeField}>{sf.field}:</span>
|
<span className={styles.scopeField}>{sf.field}:</span>
|
||||||
@@ -197,7 +210,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
onClick={() => removeScopeFilter(i)}
|
onClick={() => removeScopeFilter(i)}
|
||||||
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
||||||
>
|
>
|
||||||
×
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -210,6 +223,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value)
|
setQuery(e.target.value)
|
||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
|
userNavigated.current = false
|
||||||
onQueryChange?.(e.target.value)
|
onQueryChange?.(e.target.value)
|
||||||
}}
|
}}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
@@ -279,7 +293,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
onSelect(result)
|
onSelect(result)
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setFocusedIdx(flatIdx)}
|
onMouseEnter={() => { userNavigated.current = true; setFocusedIdx(flatIdx) }}
|
||||||
>
|
>
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
{result.icon && (
|
{result.icon && (
|
||||||
@@ -321,7 +335,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
aria-label="Toggle detail"
|
aria-label="Toggle detail"
|
||||||
>
|
>
|
||||||
{isExpanded ? '▲' : '▼'}
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +364,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Enter" />
|
<KeyboardHint keys="Enter" />
|
||||||
<span>Open</span>
|
<span>Search</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.shortcut}>
|
<div className={styles.shortcut}>
|
||||||
<KeyboardHint keys="Esc" />
|
<KeyboardHint keys="Esc" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -12,6 +12,23 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fillHeight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fillHeight .footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -35,6 +52,9 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: color 0.12s;
|
transition: color 0.12s;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable {
|
.th.sortable {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
rowAccent,
|
rowAccent,
|
||||||
expandedContent,
|
expandedContent,
|
||||||
flush = false,
|
flush = false,
|
||||||
|
fillHeight = false,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
@@ -81,7 +82,7 @@ export function DataTable<T extends { id: string }>({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.wrapper} ${flush ? styles.flush : ''}`}>
|
<div className={`${styles.wrapper} ${flush ? styles.flush : ''} ${fillHeight ? styles.fillHeight : ''}`}>
|
||||||
<div className={styles.scroll}>
|
<div className={styles.scroll}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface DataTableProps<T extends { id: string }> {
|
|||||||
expandedContent?: (row: T) => ReactNode | null
|
expandedContent?: (row: T) => ReactNode | null
|
||||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||||
flush?: boolean
|
flush?: boolean
|
||||||
|
/** Make the table fill remaining vertical space in a flex parent.
|
||||||
|
* The table body scrolls while the header stays sticky and the
|
||||||
|
* pagination footer stays pinned at the bottom. */
|
||||||
|
fillHeight?: boolean
|
||||||
/** Controlled sort: called when the user clicks a sortable column header.
|
/** Controlled sort: called when the user clicks a sortable column header.
|
||||||
* When provided, the component skips client-side sorting — the caller is
|
* When provided, the component skips client-side sorting — the caller is
|
||||||
* responsible for providing `data` in the desired order. */
|
* responsible for providing `data` in the desired order. */
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 99;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100vh;
|
||||||
width: 0;
|
width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: width 0.25s ease, opacity 0.2s ease;
|
transition: width 0.25s ease, opacity 0.2s ease;
|
||||||
@@ -7,13 +24,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
flex-shrink: 0;
|
z-index: 100;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.open {
|
.panel.open {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-left-color: var(--border);
|
border-left-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
animation: slideInRight 0.25s ease-out both;
|
animation: slideInRight 0.25s ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,51 +23,54 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
|||||||
|
|
||||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||||
|
|
||||||
const panel = (
|
const content = (
|
||||||
<aside
|
<>
|
||||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
{open && <div className={styles.backdrop} onClick={onClose} aria-hidden="true" />}
|
||||||
aria-hidden={!open}
|
<aside
|
||||||
>
|
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||||
<div className={styles.header}>
|
aria-hidden={!open}
|
||||||
<span className={styles.title}>{title}</span>
|
>
|
||||||
<button
|
<div className={styles.header}>
|
||||||
className={styles.closeBtn}
|
<span className={styles.title}>{title}</span>
|
||||||
onClick={onClose}
|
<button
|
||||||
aria-label="Close panel"
|
className={styles.closeBtn}
|
||||||
type="button"
|
onClick={onClose}
|
||||||
>
|
aria-label="Close panel"
|
||||||
×
|
type="button"
|
||||||
</button>
|
>
|
||||||
</div>
|
×
|
||||||
|
</button>
|
||||||
{tabs && tabs.length > 0 && (
|
|
||||||
<div className={styles.tabs}>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.value}
|
|
||||||
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.body}>
|
{tabs && tabs.length > 0 && (
|
||||||
{children ?? activeContent}
|
<div className={styles.tabs}>
|
||||||
</div>
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
className={`${styles.tab} ${tab.value === activeTab ? styles.activeTab : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{actions && (
|
<div className={styles.body}>
|
||||||
<div className={styles.actions}>
|
{children ?? activeContent}
|
||||||
{actions}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
{actions && (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Portal to AppShell level if target exists, otherwise render in place
|
// Portal to AppShell level if target exists, otherwise render in place
|
||||||
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||||
return portalTarget ? createPortal(panel, portalTarget) : panel
|
return portalTarget ? createPortal(content, portalTarget) : content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
import { type ReactNode, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { X as XIcon, AlertTriangle, Play, Loader } from 'lucide-react'
|
||||||
import styles from './EventFeed.module.css'
|
import styles from './EventFeed.module.css'
|
||||||
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
|
||||||
@@ -47,11 +48,11 @@ function getSearchableText(event: FeedEvent): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<SeverityFilter, string> = {
|
const DEFAULT_ICONS: Record<SeverityFilter, ReactNode> = {
|
||||||
error: '\u2715', // ✕
|
error: <XIcon size={14} />,
|
||||||
warning: '\u26A0', // ⚠
|
warning: <AlertTriangle size={14} />,
|
||||||
success: '\u25B6', // ▶
|
success: <Play size={14} />,
|
||||||
running: '\u2699', // ⚙
|
running: <Loader size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
const SEVERITY_COLORS: Record<SeverityFilter, string> = {
|
||||||
@@ -81,25 +82,25 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
.filter((e) => activeFilters.size === 0 || activeFilters.has(e.severity))
|
||||||
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
.filter((e) => !searchLower || getSearchableText(e).toLowerCase().includes(searchLower))
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to top (newest entries are at top in desc sort)
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPaused) {
|
if (!isPaused) {
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}
|
}
|
||||||
}, [events, isPaused, scrollToBottom])
|
}, [events, isPaused, scrollToTop])
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8
|
const atTop = el.scrollTop < 8
|
||||||
setIsPaused(!atBottom)
|
setIsPaused(!atTop)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFilter(severity: SeverityFilter) {
|
function toggleFilter(severity: SeverityFilter) {
|
||||||
@@ -136,7 +137,7 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<XIcon size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,10 +197,10 @@ export function EventFeed({ events, maxItems = 200, className }: EventFeedProps)
|
|||||||
className={styles.resumeBtn}
|
className={styles.resumeBtn}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
scrollToBottom()
|
scrollToTop()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↓ Resume auto-scroll
|
↑ Scroll to latest
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type ChangeEvent } from 'react'
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './FilterBar.module.css'
|
import styles from './FilterBar.module.css'
|
||||||
import { Input } from '../../primitives/Input/Input'
|
import { Input } from '../../primitives/Input/Input'
|
||||||
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||||
@@ -77,12 +78,7 @@ export function FilterBar({
|
|||||||
if (onSearchChange) onSearchChange('')
|
if (onSearchChange) onSearchChange('')
|
||||||
else setInternalSearch('')
|
else setInternalSearch('')
|
||||||
} : undefined}
|
} : undefined}
|
||||||
icon={
|
icon={<Search size={13} />}
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import styles from './KpiStrip.module.css'
|
import styles from './KpiStrip.module.css'
|
||||||
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
import { Sparkline } from '../../primitives/Sparkline/Sparkline'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
export interface KpiItem {
|
export interface KpiItem {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
trend?: { label: string; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
trend?: { label: ReactNode; variant?: 'success' | 'warning' | 'error' | 'muted' }
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
sparkline?: number[]
|
sparkline?: number[]
|
||||||
borderColor?: string
|
borderColor?: string
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
.container {
|
.container {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-inset);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -58,6 +56,11 @@
|
|||||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.levelTrace {
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const entries: LogEntry[] = [
|
|||||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
||||||
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
||||||
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
||||||
|
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('LogViewer', () => {
|
describe('LogViewer', () => {
|
||||||
@@ -16,14 +17,16 @@ describe('LogViewer', () => {
|
|||||||
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
expect(screen.getByText('High memory usage')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Entering handleRequest()')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => {
|
it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG, TRACE)', () => {
|
||||||
render(<LogViewer entries={entries} />)
|
render(<LogViewer entries={entries} />)
|
||||||
expect(screen.getByText('INFO')).toBeInTheDocument()
|
expect(screen.getByText('INFO')).toBeInTheDocument()
|
||||||
expect(screen.getByText('WARN')).toBeInTheDocument()
|
expect(screen.getByText('WARN')).toBeInTheDocument()
|
||||||
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
expect(screen.getByText('ERROR')).toBeInTheDocument()
|
||||||
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
expect(screen.getByText('DEBUG')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('TRACE')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders with custom maxHeight (number)', () => {
|
it('renders with custom maxHeight (number)', () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import styles from './LogViewer.module.css'
|
|||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
level: 'info' | 'warn' | 'error' | 'debug'
|
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
|||||||
warn: styles.levelWarn,
|
warn: styles.levelWarn,
|
||||||
error: styles.levelError,
|
error: styles.levelError,
|
||||||
debug: styles.levelDebug,
|
debug: styles.levelDebug,
|
||||||
|
trace: styles.levelTrace,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
@@ -35,18 +36,18 @@ function formatTime(iso: string): string {
|
|||||||
|
|
||||||
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const isAtBottomRef = useRef(true)
|
const isAtTopRef = useRef(true)
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20
|
isAtTopRef.current = el.scrollTop < 20
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el && isAtBottomRef.current) {
|
if (el && isAtTopRef.current) {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [entries])
|
}, [entries])
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './ProcessorTimeline.module.css'
|
import styles from './ProcessorTimeline.module.css'
|
||||||
import { Dropdown } from '../Dropdown/Dropdown'
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||||
@@ -124,7 +125,7 @@ export function ProcessorTimeline({
|
|||||||
aria-label={`Actions for ${proc.name}`}
|
aria-label={`Actions for ${proc.name}`}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
⋮
|
<EllipsisVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
items={resolvedActions}
|
items={resolvedActions}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
|
||||||
import styles from './RouteFlow.module.css'
|
import styles from './RouteFlow.module.css'
|
||||||
import { Dropdown } from '../Dropdown/Dropdown'
|
import { Dropdown } from '../Dropdown/Dropdown'
|
||||||
|
|
||||||
@@ -55,12 +56,12 @@ function durationClass(ms: number, status: string): string {
|
|||||||
return styles.durBreach
|
return styles.durBreach
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, string> = {
|
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||||
'from': '\u25B6',
|
'from': <Play size={14} />,
|
||||||
'process': '\u2699',
|
'process': <Cog size={14} />,
|
||||||
'to': '\u25A2',
|
'to': <Square size={14} />,
|
||||||
'choice': '\u25C6',
|
'choice': <Diamond size={14} />,
|
||||||
'error-handler': '\u26A0',
|
'error-handler': <AlertTriangle size={14} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_CLASSES: Record<string, string> = {
|
const ICON_CLASSES: Record<string, string> = {
|
||||||
@@ -100,7 +101,7 @@ function renderActionTrigger(
|
|||||||
aria-label={`Actions for ${node.name}`}
|
aria-label={`Actions for ${node.name}`}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
⋮
|
<EllipsisVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
items={resolvedActions}
|
items={resolvedActions}
|
||||||
@@ -166,7 +167,7 @@ function renderNodeChain(
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<div className={styles.type}>{node.type}</div>
|
<div className={styles.type}>{node.type}</div>
|
||||||
@@ -260,7 +261,7 @@ export function RouteFlow({ nodes, flows, onNodeClick, selectedIndex, actions, g
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
{TYPE_ICONS[node.type] ?? <Square size={14} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<div className={styles.type}>{node.type}</div>
|
<div className={styles.type}>{node.type}</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
act(() => { getApi().toast({ title: 'Info', variant: 'info' }) })
|
||||||
|
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for success variant', () => {
|
it('shows correct icon for success variant', () => {
|
||||||
@@ -97,7 +97,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
act(() => { getApi().toast({ title: 'OK', variant: 'success' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for warning variant', () => {
|
it('shows correct icon for warning variant', () => {
|
||||||
@@ -105,7 +105,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
act(() => { getApi().toast({ title: 'Warn', variant: 'warning' }) })
|
||||||
|
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows correct icon for error variant', () => {
|
it('shows correct icon for error variant', () => {
|
||||||
@@ -113,7 +113,7 @@ describe('Toast', () => {
|
|||||||
|
|
||||||
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
act(() => { getApi().toast({ title: 'Err', variant: 'error' }) })
|
||||||
|
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
expect(screen.getByTestId('toast').querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('dismisses toast when close button is clicked', () => {
|
it('dismisses toast when close button is clicked', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Toast.module.css'
|
import styles from './Toast.module.css'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -39,11 +40,11 @@ const MAX_TOASTS = 5
|
|||||||
const DEFAULT_DURATION = 5000
|
const DEFAULT_DURATION = 5000
|
||||||
const EXIT_ANIMATION_MS = 300
|
const EXIT_ANIMATION_MS = 300
|
||||||
|
|
||||||
const ICONS: Record<ToastVariant, string> = {
|
const ICONS: Record<ToastVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context ────────────────────────────────────────────────────────────────
|
// ── Context ────────────────────────────────────────────────────────────────
|
||||||
@@ -183,7 +184,7 @@ function ToastItemComponent({ toast, onDismiss }: ToastItemComponentProps) {
|
|||||||
aria-label="Dismiss notification"
|
aria-label="Dismiss notification"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ export * from './layout'
|
|||||||
export * from './providers/ThemeProvider'
|
export * from './providers/ThemeProvider'
|
||||||
export * from './providers/CommandPaletteProvider'
|
export * from './providers/CommandPaletteProvider'
|
||||||
export * from './providers/GlobalFilterProvider'
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export { BreadcrumbProvider, useBreadcrumb } from './providers/BreadcrumbProvider'
|
||||||
|
export type { BreadcrumbItem } from './providers/BreadcrumbProvider'
|
||||||
export * from './utils/hashColor'
|
export * from './utils/hashColor'
|
||||||
export * from './utils/timePresets'
|
export * from './utils/timePresets'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Search, X, ChevronRight, ChevronDown, Settings, FileText } from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||||
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
import { SidebarTree, type SidebarTreeNode } from './SidebarTree'
|
||||||
@@ -33,6 +34,7 @@ export interface SidebarAgent {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
apps: SidebarApp[]
|
apps: SidebarApp[]
|
||||||
className?: string
|
className?: string
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -55,7 +57,7 @@ function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|||||||
id: `route:${app.id}:${route.id}`,
|
id: `route:${app.id}:${route.id}`,
|
||||||
starKey: `${app.id}:${route.id}`,
|
starKey: `${app.id}:${route.id}`,
|
||||||
label: route.name,
|
label: route.name,
|
||||||
icon: <span className={styles.routeArrow}>▸</span>,
|
icon: <ChevronRight size={12} />,
|
||||||
badge: formatCount(route.exchangeCount),
|
badge: formatCount(route.exchangeCount),
|
||||||
path: `/apps/${app.id}/${route.id}`,
|
path: `/apps/${app.id}/${route.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
@@ -78,7 +80,7 @@ function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
|
|||||||
id: `routestat:${app.id}:${route.id}`,
|
id: `routestat:${app.id}:${route.id}`,
|
||||||
starKey: `routes:${app.id}:${route.id}`,
|
starKey: `routes:${app.id}:${route.id}`,
|
||||||
label: route.name,
|
label: route.name,
|
||||||
icon: <span className={styles.routeArrow}>▸</span>,
|
icon: <ChevronRight size={12} />,
|
||||||
badge: formatCount(route.exchangeCount),
|
badge: formatCount(route.exchangeCount),
|
||||||
path: `/routes/${app.id}/${route.id}`,
|
path: `/routes/${app.id}/${route.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
@@ -235,10 +237,7 @@ function StarredGroup({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={`Remove ${item.label} from starred`}
|
aria-label={`Remove ${item.label} from starred`}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<X size={12} />
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -248,7 +247,7 @@ function StarredGroup({
|
|||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Sidebar({ apps, className }: SidebarProps) {
|
export function Sidebar({ apps, className, onNavigate }: SidebarProps) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
|
||||||
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
|
||||||
@@ -277,7 +276,8 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const nav = onNavigate ?? routerNavigate
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { starredIds, isStarred, toggleStar } = useStarred()
|
const { starredIds, isStarred, toggleStar } = useStarred()
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
|
<div className={styles.logo} onClick={() => nav('/apps')} style={{ cursor: 'pointer' }}>
|
||||||
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
|
||||||
<div>
|
<div>
|
||||||
<span className={styles.brand}>cameleer</span>
|
<span className={styles.brand}>cameleer</span>
|
||||||
@@ -344,10 +344,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<div className={styles.searchWrap}>
|
<div className={styles.searchWrap}>
|
||||||
<div className={styles.searchInner}>
|
<div className={styles.searchInner}>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<Search size={12} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className={styles.searchInput}
|
className={styles.searchInput}
|
||||||
@@ -363,7 +360,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,14 +379,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!appsCollapsed}
|
aria-expanded={!appsCollapsed}
|
||||||
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
|
||||||
>
|
>
|
||||||
{appsCollapsed ? '▸' : '▾'}
|
{appsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/apps')}
|
onClick={() => nav('/apps')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/apps') }}
|
||||||
>
|
>
|
||||||
Applications
|
Applications
|
||||||
</span>
|
</span>
|
||||||
@@ -403,6 +400,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:apps"
|
persistKey="cameleer:expanded:apps"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -416,14 +414,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!agentsCollapsed}
|
aria-expanded={!agentsCollapsed}
|
||||||
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
|
||||||
>
|
>
|
||||||
{agentsCollapsed ? '▸' : '▾'}
|
{agentsCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/agents')}
|
onClick={() => nav('/agents')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/agents') }}
|
||||||
>
|
>
|
||||||
Agents
|
Agents
|
||||||
</span>
|
</span>
|
||||||
@@ -437,6 +435,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:agents"
|
persistKey="cameleer:expanded:agents"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -450,14 +449,14 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
aria-expanded={!routesCollapsed}
|
aria-expanded={!routesCollapsed}
|
||||||
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
aria-label={routesCollapsed ? 'Expand Routes' : 'Collapse Routes'}
|
||||||
>
|
>
|
||||||
{routesCollapsed ? '▸' : '▾'}
|
{routesCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
className={`${styles.treeSectionLabel} ${location.pathname === '/routes' ? styles.treeSectionLabelActive : ''}`}
|
||||||
onClick={() => navigate('/routes')}
|
onClick={() => nav('/routes')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/routes') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/routes') }}
|
||||||
>
|
>
|
||||||
Routes
|
Routes
|
||||||
</span>
|
</span>
|
||||||
@@ -471,6 +470,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:routes"
|
persistKey="cameleer:expanded:routes"
|
||||||
autoRevealPath={sidebarRevealPath}
|
autoRevealPath={sidebarRevealPath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -491,7 +491,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Applications"
|
label="Applications"
|
||||||
items={starredApps}
|
items={starredApps}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -499,7 +499,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRoutes}
|
items={starredRoutes}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -507,7 +507,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Agents"
|
label="Agents"
|
||||||
items={starredAgents}
|
items={starredAgents}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -515,7 +515,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Routes"
|
label="Routes"
|
||||||
items={starredRouteStats}
|
items={starredRouteStats}
|
||||||
onNavigate={navigate}
|
onNavigate={nav}
|
||||||
onRemove={toggleStar}
|
onRemove={toggleStar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -531,12 +531,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
location.pathname.startsWith('/admin') ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => nav('/admin')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/admin') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/admin') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}>⚙</span>
|
<span className={styles.bottomIcon}><Settings size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
<div className={styles.itemName}>Admin</div>
|
<div className={styles.itemName}>Admin</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -546,12 +546,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
styles.bottomItem,
|
styles.bottomItem,
|
||||||
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
location.pathname === '/api-docs' ? styles.bottomItemActive : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => navigate('/api-docs')}
|
onClick={() => nav('/api-docs')}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/api-docs') }}
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') nav('/api-docs') }}
|
||||||
>
|
>
|
||||||
<span className={styles.bottomIcon}>☰</span>
|
<span className={styles.bottomIcon}><FileText size={14} /></span>
|
||||||
<div className={styles.itemInfo}>
|
<div className={styles.itemInfo}>
|
||||||
<div className={styles.itemName}>API Docs</div>
|
<div className={styles.itemName}>API Docs</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Star, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -33,24 +34,17 @@ export interface SidebarTreeProps {
|
|||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||||
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||||
|
onNavigate?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
// ── Star icons ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StarOutline() {
|
function StarOutline() {
|
||||||
return (
|
return <Star size={14} />
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarFilled() {
|
function StarFilled() {
|
||||||
return (
|
return <Star size={14} fill="currentColor" />
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistent expand state ──────────────────────────────────────────────────
|
// ── Persistent expand state ──────────────────────────────────────────────────
|
||||||
@@ -141,8 +135,10 @@ export function SidebarTree({
|
|||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
autoRevealPath,
|
autoRevealPath,
|
||||||
|
onNavigate,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const routerNavigate = useNavigate()
|
||||||
|
const navigate = onNavigate ?? routerNavigate
|
||||||
|
|
||||||
// Expand/collapse state — optionally persisted to sessionStorage
|
// Expand/collapse state — optionally persisted to sessionStorage
|
||||||
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
const [userExpandedIds, setUserExpandedIds] = useState<Set<string>>(
|
||||||
@@ -395,7 +391,7 @@ function SidebarTreeRow({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
{isExpanded ? '▾' : '▸'}
|
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Search, Moon, Sun, Power } from 'lucide-react'
|
||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||||
@@ -8,11 +9,8 @@ import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeD
|
|||||||
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
|
||||||
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||||
import { useTheme } from '../../providers/ThemeProvider'
|
import { useTheme } from '../../providers/ThemeProvider'
|
||||||
|
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||||
interface BreadcrumbItem {
|
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||||
label: string
|
|
||||||
href?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
@@ -39,11 +37,12 @@ export function TopBar({
|
|||||||
const globalFilters = useGlobalFilters()
|
const globalFilters = useGlobalFilters()
|
||||||
const commandPalette = useCommandPalette()
|
const commandPalette = useCommandPalette()
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const breadcrumbOverride = useBreadcrumbOverride()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
{/* Search trigger */}
|
{/* Search trigger */}
|
||||||
<button
|
<button
|
||||||
@@ -53,10 +52,7 @@ export function TopBar({
|
|||||||
aria-label="Open search"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
<span className={styles.searchIcon} aria-hidden="true">
|
<span className={styles.searchIcon} aria-hidden="true">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<Search size={13} />
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
@@ -103,7 +99,7 @@ export function TopBar({
|
|||||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
>
|
>
|
||||||
{theme === 'light' ? '\u263E' : '\u2600'}
|
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
</button>
|
</button>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<span className={styles.env}>{environment}</span>
|
||||||
@@ -117,7 +113,7 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Logout', icon: '\u23FB', onClick: onLogout },
|
{ label: 'Logout', icon: <Power size={14} />, onClick: onLogout },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,24 +46,23 @@ describe('Alert', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows default icon for each variant', () => {
|
it('shows default icon for each variant', () => {
|
||||||
const { rerender } = render(<Alert variant="info">msg</Alert>)
|
const { container, rerender } = render(<Alert variant="info">msg</Alert>)
|
||||||
expect(screen.getByText('ℹ')).toBeInTheDocument()
|
// Each variant should render an SVG icon in the icon slot
|
||||||
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="success">msg</Alert>)
|
rerender(<Alert variant="success">msg</Alert>)
|
||||||
expect(screen.getByText('✓')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="warning">msg</Alert>)
|
rerender(<Alert variant="warning">msg</Alert>)
|
||||||
expect(screen.getByText('⚠')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<Alert variant="error">msg</Alert>)
|
rerender(<Alert variant="error">msg</Alert>)
|
||||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
expect(container.querySelector('[aria-hidden="true"] svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a custom icon when provided', () => {
|
it('renders a custom icon when provided', () => {
|
||||||
render(<Alert icon={<span>★</span>}>Custom icon alert</Alert>)
|
render(<Alert icon={<span data-testid="custom-icon">★</span>}>Custom icon alert</Alert>)
|
||||||
expect(screen.getByText('★')).toBeInTheDocument()
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||||
// Default icon should not appear
|
|
||||||
expect(screen.queryByText('ℹ')).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show dismiss button when dismissible is false', () => {
|
it('does not show dismiss button when dismissible is false', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
import { Info, CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react'
|
||||||
import styles from './Alert.module.css'
|
import styles from './Alert.module.css'
|
||||||
|
|
||||||
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||||
@@ -13,11 +14,11 @@ interface AlertProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ICONS: Record<AlertVariant, string> = {
|
const DEFAULT_ICONS: Record<AlertVariant, ReactNode> = {
|
||||||
info: 'ℹ',
|
info: <Info size={16} />,
|
||||||
success: '✓',
|
success: <CheckCircle size={16} />,
|
||||||
warning: '⚠',
|
warning: <AlertTriangle size={16} />,
|
||||||
error: '✕',
|
error: <XCircle size={16} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
const ARIA_ROLES: Record<AlertVariant, 'alert' | 'status'> = {
|
||||||
@@ -61,7 +62,7 @@ export function Alert({
|
|||||||
aria-label="Dismiss alert"
|
aria-label="Dismiss alert"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import styles from './Input.module.css'
|
import styles from './Input.module.css'
|
||||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
@@ -25,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
44
src/design-system/providers/BreadcrumbProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbContextValue {
|
||||||
|
override: BreadcrumbItem[] | null
|
||||||
|
setOverride: (items: BreadcrumbItem[] | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BreadcrumbContext = createContext<BreadcrumbContextValue>({
|
||||||
|
override: null,
|
||||||
|
setOverride: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [override, setOverride] = useState<BreadcrumbItem[] | null>(null)
|
||||||
|
return (
|
||||||
|
<BreadcrumbContext.Provider value={{ override, setOverride }}>
|
||||||
|
{children}
|
||||||
|
</BreadcrumbContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the TopBar breadcrumb with page-specific semantic items.
|
||||||
|
* Pass `null` to clear (or let unmount handle it).
|
||||||
|
* Callers should `useMemo` the items array to avoid unnecessary re-renders.
|
||||||
|
*/
|
||||||
|
export function useBreadcrumb(items: BreadcrumbItem[] | null) {
|
||||||
|
const { setOverride } = useContext(BreadcrumbContext)
|
||||||
|
useEffect(() => {
|
||||||
|
setOverride(items)
|
||||||
|
return () => setOverride(null)
|
||||||
|
}, [items, setOverride])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal — used by TopBar to read the current override. */
|
||||||
|
export function useBreadcrumbOverride(): BreadcrumbItem[] | null {
|
||||||
|
return useContext(BreadcrumbContext).override
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||||
import { computePresetRange } from '../utils/timePresets'
|
import { computePresetRange } from '../utils/timePresets'
|
||||||
|
|
||||||
export interface TimeRange {
|
export interface TimeRange {
|
||||||
@@ -66,6 +66,16 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Keep the time range sliding forward when a preset is active and live
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh || !timeRange.preset) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const { start, end } = computePresetRange(timeRange.preset!)
|
||||||
|
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||||
|
}, 10_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [autoRefresh, timeRange.preset])
|
||||||
|
|
||||||
const isInTimeRange = useCallback(
|
const isInTimeRange = useCallback(
|
||||||
(timestamp: Date) => {
|
(timestamp: Date) => {
|
||||||
if (timeRange.preset) {
|
if (timeRange.preset) {
|
||||||
|
|||||||
@@ -313,3 +313,74 @@ export const exchanges: Exchange[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Generate additional exchanges to reach ~200 total ────────────────────────
|
||||||
|
|
||||||
|
const GEN_ROUTES = [
|
||||||
|
'order-intake', 'payment-validate', 'order-enrichment', 'shipment-dispatch',
|
||||||
|
'payment-process', 'shipment-track', 'inventory-sync', 'notification-send',
|
||||||
|
'audit-log', 'customer-update', 'refund-process', 'invoice-generate',
|
||||||
|
]
|
||||||
|
const GEN_ROUTE_GROUPS = [
|
||||||
|
'order-flow', 'payment-flow', 'shipment-flow', 'inventory-flow',
|
||||||
|
'notification-flow', 'audit-flow', 'customer-flow', 'billing-flow',
|
||||||
|
]
|
||||||
|
const GEN_AGENTS = ['prod-1', 'prod-2', 'prod-3', 'prod-4']
|
||||||
|
const GEN_STATUSES: Exchange['status'][] = [
|
||||||
|
'completed', 'completed', 'completed', 'completed', 'completed',
|
||||||
|
'completed', 'failed', 'warning', 'running',
|
||||||
|
]
|
||||||
|
const GEN_PROCESSORS: ProcessorData[][] = [
|
||||||
|
[
|
||||||
|
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
|
||||||
|
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
|
||||||
|
{ name: 'validate(schema)', type: 'process', durationMs: 10, status: 'ok', startMs: 10 },
|
||||||
|
{ name: 'to(target)', type: 'to', durationMs: 15, status: 'ok', startMs: 20 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
|
||||||
|
{ name: 'enrich(external-api)', type: 'enrich', durationMs: 120, status: 'slow', startMs: 5 },
|
||||||
|
{ name: 'to(target)', type: 'to', durationMs: 20, status: 'ok', startMs: 125 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: 'from(jms:queue)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
|
||||||
|
{ name: 'to(external-gateway)', type: 'to', durationMs: 400, status: 'fail', startMs: 3 },
|
||||||
|
],
|
||||||
|
]
|
||||||
|
const GEN_ERRORS = [
|
||||||
|
{ msg: 'org.apache.camel.CamelExecutionException: Timeout after 5000ms', cls: 'org.apache.camel.CamelExecutionException' },
|
||||||
|
{ msg: 'java.sql.SQLTransientConnectionException: Connection pool exhausted', cls: 'java.sql.SQLTransientConnectionException' },
|
||||||
|
{ msg: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP 502 Bad Gateway', cls: 'org.apache.camel.component.http.HttpOperationFailedException' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BASE_TIME = new Date('2026-03-18T08:00:00').getTime()
|
||||||
|
|
||||||
|
for (let i = 0; i < 185; i++) {
|
||||||
|
const idx = exchanges.length
|
||||||
|
const status = GEN_STATUSES[i % GEN_STATUSES.length]
|
||||||
|
const route = GEN_ROUTES[i % GEN_ROUTES.length]
|
||||||
|
const routeGroup = GEN_ROUTE_GROUPS[i % GEN_ROUTE_GROUPS.length]
|
||||||
|
const isFailed = status === 'failed'
|
||||||
|
const durationMs = isFailed
|
||||||
|
? 1000 + ((i * 37) % 4000)
|
||||||
|
: status === 'running'
|
||||||
|
? 10000 + ((i * 53) % 20000)
|
||||||
|
: 30 + ((i * 73) % 400)
|
||||||
|
const err = isFailed ? GEN_ERRORS[i % GEN_ERRORS.length] : undefined
|
||||||
|
|
||||||
|
exchanges.push({
|
||||||
|
id: `E-2026-03-18-${String(idx + 200).padStart(5, '0')}`,
|
||||||
|
orderId: `OP-${80000 + idx}`,
|
||||||
|
customer: `CUST-${10000 + ((i * 131) % 90000)}`,
|
||||||
|
route,
|
||||||
|
routeGroup,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
timestamp: new Date(BASE_TIME - i * 2 * 60 * 1000),
|
||||||
|
correlationId: `cmr-${i.toString(16).padStart(8, '0')}-gen`,
|
||||||
|
agent: GEN_AGENTS[i % GEN_AGENTS.length],
|
||||||
|
...(err ? { errorMessage: err.msg, errorClass: err.cls } : {}),
|
||||||
|
processors: GEN_PROCESSORS[i % GEN_PROCESSORS.length].map((p) => ({ ...p })),
|
||||||
|
...(i % 3 === 0 ? { correlationGroup: `${routeGroup}-${String(Math.floor(i / 3)).padStart(3, '0')}` } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentHealth.module.css'
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -389,7 +390,7 @@ export function AgentHealth() {
|
|||||||
{scope.level !== 'all' && (
|
{scope.level !== 'all' && (
|
||||||
<>
|
<>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
<span className={styles.scopeCurrent}>{scope.appId}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
import styles from './AgentInstance.module.css'
|
import styles from './AgentInstance.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -177,9 +178,9 @@ export function AgentInstance() {
|
|||||||
{/* Scope trail + badges */}
|
{/* Scope trail + badges */}
|
||||||
<div className={styles.scopeTrail}>
|
<div className={styles.scopeTrail}>
|
||||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
<Link to={`/agents/${appId}`} className={styles.scopeLink}>{appId}</Link>
|
||||||
<span className={styles.scopeSep}>▸</span>
|
<span className={styles.scopeSep}><ChevronRight size={12} /></span>
|
||||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||||
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
<Badge label={agent.status.toUpperCase()} color={statusColor} />
|
||||||
<Badge label={agent.version} color="auto" variant="outlined" />
|
<Badge label={agent.version} color="auto" variant="outlined" />
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 24px 40px;
|
padding: 20px 24px 40px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter bar spacing */
|
/* Filter bar spacing */
|
||||||
@@ -19,6 +22,10 @@
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableHeader {
|
.tableHeader {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { TrendingUp, TrendingDown, ArrowRight, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -43,10 +44,10 @@ const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
|
|||||||
warning: 'var(--warning)',
|
warning: 'var(--warning)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TREND_ICONS: Record<KpiMetric['trend'], string> = {
|
const TREND_ICONS: Record<KpiMetric['trend'], React.ReactNode> = {
|
||||||
up: '\u2191',
|
up: <TrendingUp size={12} />,
|
||||||
down: '\u2193',
|
down: <TrendingDown size={12} />,
|
||||||
neutral: '\u2192',
|
neutral: <ArrowRight size={12} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
||||||
@@ -60,7 +61,7 @@ function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' |
|
|||||||
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
||||||
label: m.label,
|
label: m.label,
|
||||||
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
||||||
trend: { label: `${TREND_ICONS[m.trend]} ${m.trendValue}`, variant: sentimentToVariant(m.trendSentiment) },
|
trend: { label: <><span style={{ display: 'inline-flex', verticalAlign: 'middle' }}>{TREND_ICONS[m.trend]}</span> {m.trendValue}</>, variant: sentimentToVariant(m.trendSentiment) },
|
||||||
subtitle: m.detail,
|
subtitle: m.detail,
|
||||||
sparkline: m.sparkline,
|
sparkline: m.sparkline,
|
||||||
borderColor: ACCENT_TO_COLOR[m.accent],
|
borderColor: ACCENT_TO_COLOR[m.accent],
|
||||||
@@ -206,7 +207,7 @@ export function Dashboard() {
|
|||||||
navigate(`/exchanges/${row.id}`)
|
navigate(`/exchanges/${row.id}`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↗
|
<ExternalLink size={14} />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -303,7 +304,7 @@ export function Dashboard() {
|
|||||||
className={styles.openDetailLink}
|
className={styles.openDetailLink}
|
||||||
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
onClick={() => navigate(`/exchanges/${selectedExchange.id}`)}
|
||||||
>
|
>
|
||||||
Open full details →
|
Open full details <ArrowRight size={14} style={{ verticalAlign: 'middle' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,11 +425,12 @@ export function Dashboard() {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
sortable
|
sortable
|
||||||
flush
|
flush
|
||||||
|
fillHeight
|
||||||
rowAccent={handleRowAccent}
|
rowAccent={handleRowAccent}
|
||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
<div className={styles.inlineError}>
|
<div className={styles.inlineError}>
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Hexagon, ArrowRight, Diamond, Eye, Pencil, RotateCcw, Trash2, ChevronDown } from 'lucide-react'
|
||||||
import styles from './CompositesSection.module.css'
|
import styles from './CompositesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -134,13 +135,27 @@ interface TableRow {
|
|||||||
exchanges: number
|
exchanges: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABLE_DATA: TableRow[] = [
|
const ROUTE_PREFIXES = [
|
||||||
{ id: '1', name: 'order-ingest', method: 'POST', status: 'live', exchanges: 1243 },
|
'order', 'payment', 'inventory', 'customer', 'shipment', 'user', 'cart',
|
||||||
{ id: '2', name: 'payment-validate', method: 'POST', status: 'live', exchanges: 987 },
|
'refund', 'stock', 'email', 'webhook', 'cache', 'log', 'rate', 'health',
|
||||||
{ id: '3', name: 'inventory-check', method: 'GET', status: 'stale', exchanges: 432 },
|
'session', 'config', 'metrics', 'batch', 'audit', 'invoice', 'product',
|
||||||
{ id: '4', name: 'notify-customer', method: 'POST', status: 'live', exchanges: 876 },
|
'catalog', 'search', 'report', 'export', 'import', 'sync', 'alert', 'ticket',
|
||||||
{ id: '5', name: 'archive-order', method: 'PUT', status: 'dead', exchanges: 54 },
|
|
||||||
]
|
]
|
||||||
|
const ROUTE_SUFFIXES = [
|
||||||
|
'ingest', 'validate', 'check', 'notify', 'archive', 'track', 'auth',
|
||||||
|
'update', 'process', 'sync', 'dispatch', 'relay', 'invalidate', 'aggregate',
|
||||||
|
'limit', 'refresh', 'reload', 'export', 'import', 'purge',
|
||||||
|
]
|
||||||
|
const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
|
||||||
|
const STATUSES = ['live', 'live', 'live', 'live', 'stale', 'stale', 'dead']
|
||||||
|
|
||||||
|
const TABLE_DATA: TableRow[] = Array.from({ length: 500 }, (_, i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
name: `${ROUTE_PREFIXES[i % ROUTE_PREFIXES.length]}-${ROUTE_SUFFIXES[(i * 7) % ROUTE_SUFFIXES.length]}`,
|
||||||
|
method: METHODS[i % METHODS.length],
|
||||||
|
status: STATUSES[i % STATUSES.length],
|
||||||
|
exchanges: ((i * 1337 + 421) % 9990) + 10,
|
||||||
|
}))
|
||||||
|
|
||||||
const NOW = new Date()
|
const NOW = new Date()
|
||||||
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
|
const minsAgo = (n: number) => new Date(NOW.getTime() - n * 60 * 1000)
|
||||||
@@ -157,25 +172,25 @@ const TREE_NODES = [
|
|||||||
{
|
{
|
||||||
id: 'app1',
|
id: 'app1',
|
||||||
label: 'cameleer-prod',
|
label: 'cameleer-prod',
|
||||||
icon: '⬡',
|
icon: <Hexagon size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'route1',
|
id: 'route1',
|
||||||
label: 'order-ingest',
|
label: 'order-ingest',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc1', label: 'ValidateOrder', icon: '◈', meta: '12ms' },
|
{ id: 'proc1', label: 'ValidateOrder', icon: <Diamond size={12} />, meta: '12ms' },
|
||||||
{ id: 'proc2', label: 'EnrichPayload', icon: '◈', meta: '8ms' },
|
{ id: 'proc2', label: 'EnrichPayload', icon: <Diamond size={12} />, meta: '8ms' },
|
||||||
{ id: 'proc3', label: 'RouteToQueue', icon: '◈', meta: '3ms' },
|
{ id: 'proc3', label: 'RouteToQueue', icon: <Diamond size={12} />, meta: '3ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'route2',
|
id: 'route2',
|
||||||
label: 'payment-validate',
|
label: 'payment-validate',
|
||||||
icon: '→',
|
icon: <ArrowRight size={14} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'proc4', label: 'TokenizeCard', icon: '◈', meta: '22ms' },
|
{ id: 'proc4', label: 'TokenizeCard', icon: <Diamond size={12} />, meta: '22ms' },
|
||||||
{ id: 'proc5', label: 'AuthorizePayment', icon: '◈', meta: '45ms' },
|
{ id: 'proc5', label: 'AuthorizePayment', icon: <Diamond size={12} />, meta: '45ms' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -467,12 +482,13 @@ export function CompositesSection() {
|
|||||||
title="DataTable"
|
title="DataTable"
|
||||||
description="Sortable, paginated table with row click, accent rows, and page size selector."
|
description="Sortable, paginated table with row click, accent rows, and page size selector."
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%', height: 500, display: 'flex', flexDirection: 'column' }}>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
data={TABLE_DATA}
|
data={TABLE_DATA}
|
||||||
sortable
|
sortable
|
||||||
pageSize={5}
|
pageSize={25}
|
||||||
|
fillHeight
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
@@ -505,13 +521,13 @@ export function CompositesSection() {
|
|||||||
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
description="Click-triggered dropdown menu with icons, dividers, and disabled items."
|
||||||
>
|
>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={<Button size="sm" variant="secondary">Actions ▾</Button>}
|
trigger={<Button size="sm" variant="secondary">Actions <ChevronDown size={12} /></Button>}
|
||||||
items={[
|
items={[
|
||||||
{ label: 'View details', icon: '👁', onClick: () => undefined },
|
{ label: 'View details', icon: <Eye size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Edit route', icon: '✏', onClick: () => undefined },
|
{ label: 'Edit route', icon: <Pencil size={14} />, onClick: () => undefined },
|
||||||
{ divider: true, label: '' },
|
{ divider: true, label: '' },
|
||||||
{ label: 'Restart', icon: '↺', onClick: () => undefined },
|
{ label: 'Restart', icon: <RotateCcw size={14} />, onClick: () => undefined },
|
||||||
{ label: 'Delete', icon: '✕', onClick: () => undefined, disabled: true },
|
{ label: 'Delete', icon: <Trash2 size={14} />, onClick: () => undefined, disabled: true },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import styles from './PrimitivesSection.module.css'
|
import styles from './PrimitivesSection.module.css'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -358,7 +359,7 @@ export function PrimitivesSection() {
|
|||||||
description="Text input with optional leading icon and placeholder."
|
description="Text input with optional leading icon and placeholder."
|
||||||
>
|
>
|
||||||
<Input placeholder="Plain input" />
|
<Input placeholder="Plain input" />
|
||||||
<Input icon="🔍" placeholder="With icon" />
|
<Input icon={<Search size={14} />} placeholder="With icon" />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
{/* 15b. InlineEdit */}
|
{/* 15b. InlineEdit */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import styles from './RouteDetail.module.css'
|
import styles from './RouteDetail.module.css'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
@@ -337,7 +338,7 @@ export function RouteDetail() {
|
|||||||
expandedContent={(row) =>
|
expandedContent={(row) =>
|
||||||
row.errorMessage ? (
|
row.errorMessage ? (
|
||||||
<div className={styles.inlineError}>
|
<div className={styles.inlineError}>
|
||||||
<span className={styles.inlineErrorIcon}>⚠</span>
|
<span className={styles.inlineErrorIcon}><AlertTriangle size={14} /></span>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.errorClass}>{row.errorClass}</div>
|
<div className={styles.errorClass}>{row.errorClass}</div>
|
||||||
<div className={styles.errorText}>{row.errorMessage}</div>
|
<div className={styles.errorText}>{row.errorMessage}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user