Compare commits

...
44 Commits
Author SHA1 Message Date
Denozordec b8dbf15254 feat(api): update types to support CIDR in lookup queries
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 54s
CI / release (push) Successful in 3m56s
Enhanced the API type definitions to include CIDR support alongside IP and domain in the lookup functionality. Updated relevant comments and descriptions to reflect the new capabilities, ensuring clarity in the API documentation.
2026-07-31 13:24:52 +07:00
DenozordecandCursor f45b2bed90 feat(lookup): enhance lookup functionality to support CIDR queries
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Failing after 40s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 14s
CI / release (push) Skipped
Updated the lookup system to allow for CIDR queries in addition to IP and domain searches. This includes modifications to the API, frontend components, and documentation to reflect the new capabilities. The LookupSearchForm and LookupComponent were adjusted to accommodate CIDR input, and relevant tests were added to ensure functionality.

Co-authored-by: Cursor <[email protected]>
2026-07-31 13:20:18 +07:00
DenozordecandCursor b13c233679 feat(analytics): remove OperationsAnalyticsCard and update imports
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 44s
CI / web (push) Successful in 1m27s
CI / release (push) Successful in 4m25s
Deleted the OperationsAnalyticsCard component to streamline analytics functionality. Updated the analytics index file to reflect this change by removing the export for OperationsAnalyticsCard. Adjusted the QuickActionGrid component to enhance user interaction with new keyboard handling and badge functionality.

Co-authored-by: Cursor <[email protected]>
2026-07-31 13:07:26 +07:00
DenozordecandCursor 37f28dfcfd chore(tsconfig): remove baseUrl from TypeScript configuration files
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 57s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m14s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m7s
Removed the "baseUrl" property from tsconfig.base.json, apps/web/tsconfig.json, and packages/ui/tsconfig.json to streamline path resolution. Updated check-openapi-gen.sh to use 'sh' instead of 'bash' for improved compatibility and adjusted the script's error handling.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:44:31 +07:00
DenozordecandCursor 6c6e76fca3 feat(ops): protect metrics, rate-limit auth, agent secret timing, e2e smoke
CI / changes (push) Successful in 7s
CI / openapi (push) Failing after 40s
CI / web (push) Successful in 56s
CI / commitlint (push) Skipped
CI / go (push) Failing after 34s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Bearer для /metrics (EVOBGP_METRICS_TOKEN); rate limit /v1/auth/config; constant-time agent secret; OTel stub; Playwright smoke; HTTP_PROXY note; checklist обновлён.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:29:00 +07:00
DenozordecandCursor 26f5172f88 test(web): add Vitest CI, ADRs, and PR Definition of Done
FE unit-тесты селекторов/api-client в CI; ADR по durable jobs, OpenAPI codegen и legacy Svelte; PR template WEB-19; процесс KPI cross-app sync.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:27:13 +07:00
DenozordecandCursor 8fe74c1d3b feat(jobs): add durable PG queue reclaim, slog, and richer metrics
JSON slog в ключевых пакетах; Prometheus path_group, job_audit_depth, upstream breaker; job_audit ClaimQueued/ReclaimStaleRunning + Adopt loop для HA после рестарта.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:26:18 +07:00
DenozordecandCursor 13e3d21ce2 feat(web): add OpenAPI TypeScript codegen and CI freshness check
Генерируем api.gen.ts из docs/openapi.yaml (openapi-typescript), добавляем scripts/check-openapi-gen.sh в CI. Закрываем решение по X-Tenant-Id как unimplemented; обновляем docs/README под React/ReUI и apps/web.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:19:02 +07:00
DenozordecandCursor a0cfbcdab1 chore(web): prune unused ReUI demo blocks and remove legacy Svelte
Оставлен только settings-7 (используется settings tabs). Удалён web-legacy-svelte; обновлены AGENTS.md и WEB-22.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:17:07 +07:00
DenozordecandCursor c3369059af feat(web): align ops screens with ReUI PRO kit and Frame surface
OpsDashboard+afterKpi на dashboard; FrameDataGrid вместо DataGridCard; KpiStatGrid вместо SectionCards; SettingsShell без Separator. Preview: dashboard-1, stats-12, data-grid-filtering-2, settings-16.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:15:59 +07:00
DenozordecandCursor 53ce80c9ff fix(pipeline): harden upstream resilience and production shutdown
DoH через DoWithRetry; CDN preview через UpstreamHTTPDo; частичный fail CDN (EVOBGP_CDN_PARTIAL_OK); безопасный доступ к Job.Meta; drain jobs при SIGTERM; ValidateProductionEnforce при EVOBGP_PRODUCTION=1.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:11:52 +07:00
Denozordec 1e04e91dd8 feat(network): add delete functionality for speakers in network speakers card
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m6s
CI / release (push) Successful in 4m12s
Implemented a delete feature for speakers within the NetworkSpeakersCard component. This includes a confirmation dialog for deletion and integration with the delete speaker mutation. Updated the table columns to include an action button for deleting speakers, enhancing the user experience for managing network speakers.
2026-07-31 03:19:54 +07:00
Denozordec 5fbe9c9b56 feat(network): implement peer discovery features and UI integration
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s
Added functionality for peer discovery, including new API endpoints for listing, approving, and rejecting discovered peers. Updated the network queries and settings to support peer discovery configurations. Enhanced the UI to display discovered peers and integrated related settings in the tenant settings component. Updated OpenAPI documentation to reflect the new endpoints and parameters. This improves the network management capabilities by allowing dynamic peer discovery and management.
2026-07-30 22:31:59 +07:00
Denozordec fd3a217cbe feat(api): enhance community retrieval with flexible ID handling
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / go (push) Successful in 1m16s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m5s
Updated the GetCommunity function to accept both UUIDs and community titles for improved flexibility in community retrieval. Added error handling for invalid ID formats and adjusted related functions to ensure consistent behavior across memory and PostgreSQL storage. This change enhances the API's usability by allowing more intuitive community lookups.
2026-07-23 11:30:46 +07:00
Denozordec ff6efec4c5 feat(api): add endpoint to list community prefixes with pagination
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 1m4s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m17s
Introduced a new GET endpoint `/v1/communities/{id}/prefixes` to retrieve unique prefixes associated with a community, including pagination support via cursor and limit parameters. Updated OpenAPI documentation to reflect this addition. Implemented backend logic in both PostgreSQL and in-memory storage to handle the new functionality, ensuring proper authorization checks and response formatting.
2026-07-23 11:10:53 +07:00
DenozordecandCursor 738d2e2256 feat(httpapi): add local audit log with portal dual-write
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 32s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m56s
Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp).

Co-authored-by: Cursor <[email protected]>
2026-07-21 13:24:54 +07:00
Denozordec 2e3e1493f5 feat(auth): add sessionCanManageApiKeys function and update access control logic
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 53s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m2s
Introduced the sessionCanManageApiKeys function to determine if a session can manage API keys based on role, permissions, and admin status. Updated the AccessComponent to utilize this new function for enabling/disabling API key management features. Enhanced documentation to reflect changes in API key management roles and permissions, including updates to the OpenAPI specification.
2026-07-21 03:18:59 +07:00
DenozordecandCursor fd2fd8298d chore(rules): add kpi-hybrid rule for stats-12 DNA
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 18s
Закрепить только hybrid KPI из kit (KpiStatGrid / row tiles); ссылка из reui-mcp.

Co-authored-by: Cursor <[email protected]>
2026-07-21 02:36:10 +07:00
DenozordecandCursor df7cd99060 chore(security): ignore local mcp.json configs
CI / changes (push) Successful in 5s
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m9s
.cursor/mcp.json и .mcp.json в gitignore; убраны из индекса; example без секрета.

Co-authored-by: Cursor <[email protected]>
2026-07-21 00:30:06 +07:00
Denozordec c273cea067 fix: update daemon PID and timestamps in configuration files
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m9s
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 23s
Adjusted the daemon PID and updated the startedAt timestamp in the .codegraph/daemon.pid file. Modified the mcp.json configuration to change the API URL and update the authorization header for improved security. This ensures the application uses the correct process ID and API endpoint for functionality.
2026-07-21 00:25:52 +07:00
DenozordecandCursor 1f969e6cac feat(web): align NavUser chrome and ReUI PRO kit
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 55s
CI / bird2 (push) Canceled after 0s
CI / release (push) Canceled after 0s
Тема в NavUser вместо ModeToggle в header; общие kpi-cols для KPI/Quick Actions; WEB-01 → user-reui; skill 42d70dcc3d и design contract.

Co-authored-by: Cursor <[email protected]>
2026-07-21 00:23:35 +07:00
DenozordecandCursor c1132cbe19 feat(httpapi): remove firewall HTTP/UI after EvoFirewall cutover
CI / changes (push) Successful in 4s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m11s
Все /v1/firewall/* отвечают 410 Gone; UI и docs указывают на EvoFirewall.

Co-authored-by: Cursor <[email protected]>
2026-07-20 19:51:08 +07:00
Denozordec b28ad88b22 feat(auth): enhance tenant resolution in JWT handling
CI / changes (push) Successful in 4s
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 58s
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m15s
Updated the authentication logic to prioritize tenant resolution from JWT claims, specifically using `tenants.bgp` or `bgp_tenant_id` as the primary source. If these claims are absent, the system will fallback to the configured `EVOBGP_PORTAL_TENANT_ID`. This change improves the flexibility of tenant management in the authentication process. Additionally, updated related documentation to reflect these changes and added tests to ensure proper functionality.
2026-07-19 01:03:33 +07:00
Denozordec b871d62de6 feat(auth): add environment variables for portal SSO integration
Introduced new environment variables for the authentication portal in the production and microvps configurations. Updated the documentation to clarify the necessity of passing these variables to the evobgp-all service. This change enhances the authentication flow by enabling single sign-on (SSO) capabilities through JWT, ensuring a more secure and streamlined user experience.
2026-07-19 00:55:35 +07:00
DenozordecandCursor 4d83b8d673 feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations.

Co-authored-by: Cursor <[email protected]>
2026-07-18 23:23:52 +07:00
DenozordecandCursor 2820cff988 refactor(web): streamline /network page with ReUI kit
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m14s
CI / release (push) Successful in 4m23s
Сжать экран «Сеть»: KPI (stats-12) и вкладки Пиры/Спикеры через ResourcePage; убраны дубли charts и заглушка control-plane.

Co-authored-by: Cursor <[email protected]>
2026-07-18 01:12:42 +07:00
DenozordecandCursor 54b7ea3bd5 feat(lookup): resolve domain to IPs for membership check
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m6s
Для FQDN после проверки DOMAINS выполняется live DNS (A/AAAA), каждый IP проверяется по IP_RANGES и snapshots; в ответе resolved_ips / resolved_ip, UI KPI и OpenAPI обновлены.

Co-authored-by: Cursor <[email protected]>
2026-07-18 01:01:54 +07:00
Denozordec 57bcfcd9e1 fix: improve error message for invalid input in lookup functionality
CI / changes (push) Successful in 5s
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / commitlint (push) Skipped
CI / go (push) Successful in 54s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m51s
Updated the error response for invalid input in the handleLookup function to provide a clearer message indicating that the query must be an IP address or FQDN, enhancing user understanding of the input requirements.
2026-07-18 00:05:49 +07:00
Denozordec 1317a73e9a refactor: update AppShell and sidebar components for improved design consistency
CI / changes (push) Successful in 4s
CI / openapi (push) Skipped
CI / web (push) Successful in 1m4s
CI / commitlint (push) Skipped
CI / go (push) Failing after 14s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Removed unused utility functions from the AppShell component and updated the sidebar width to a fixed value for better layout control. Revised the UI design documentation to clarify the shared App Shell chrome specifications and ensure alignment with design standards. Enhanced the sidebar configuration to reflect the new width and styling guidelines.
2026-07-17 23:56:35 +07:00
Denozordec 039d2f3dd9 refactor: update dashboard quick links and app shell for improved layout and functionality
CI / changes (push) Successful in 7s
CI / openapi (push) Skipped
CI / commitlint (push) Skipped
CI / web (push) Successful in 1m4s
CI / go (push) Failing after 17s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Refactored the DashboardQuickLinks component to simplify icon classes for better semantic clarity. Updated the AppShell component to integrate the AppSwitcher and AppsMenu, enhancing navigation and user experience. Adjusted the layout of the app shell header and main content for improved consistency and alignment. Updated UI design documentation to reflect these changes and ensure adherence to shared design standards.
2026-07-17 23:15:00 +07:00
Denozordec 54a0b5b966 feat: add lookup functionality for IP/domain verification and enhance dashboard links
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 22s
CI / web (push) Successful in 49s
CI / go (push) Failing after 16s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Introduced a new lookup feature allowing users to quickly verify IP addresses or domains against community lists. Updated the DashboardQuickLinks component to include a new action for IP/domain checks, enhancing user navigation. Expanded API documentation to include the new lookup endpoint and its response structure, ensuring comprehensive coverage of the feature. Updated UI design documentation to reflect the integration of the lookup functionality.
2026-07-17 20:53:11 +07:00
Denozordec 1639ba40f3 refactor: update dashboard components to integrate QuickActionGrid and enhance settings
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 33s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m14s
Removed the deprecated DashboardQuickLinkCard and replaced it with QuickActionGrid in the DashboardQuickLinks component for improved organization and user experience. Updated the AppearanceSettingsTab to include a toggle for displaying quick actions on the dashboard, enhancing user customization options. Adjusted the SystemMonitorPopover layout for better alignment and spacing. Updated documentation to reflect the new UI preferences for quick actions.
2026-07-17 20:34:25 +07:00
Denozordec 26a96bc824 refactor: update KPI components for improved layout and clarity
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 47s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m21s
Revised the KpiStatGrid and KpiStatCardBody components to enhance the layout and presentation of KPI tiles. Updated the documentation to reflect the new horizontal compact hybrid design, ensuring better visual organization with the icon on the left and improved label and value alignment. Adjusted the skeleton loading state for a more cohesive user experience.
2026-07-17 18:37:00 +07:00
Denozordec 4fc5c96e63 refactor: enhance KPI components with variant support and improved layout
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m12s
Updated the KPI components to include a new KpiStatVariant type for better visual differentiation. Refactored the KpiStatCardBody and KpiStatGrid to utilize this variant, allowing for dynamic styling based on the KPI state. Improved the layout of the KpiStatGrid and its associated components for a more cohesive user experience, including adjustments to the Badge footer presentation.
2026-07-17 17:15:14 +07:00
Denozordec 3859983ae5 refactor: update UI components to utilize Frame for improved layout and consistency
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 2m11s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m46s
Refactored multiple components, including PanelCard, SettingsCard, and DataGridShell, to replace Card with Frame for better organization and presentation. Enhanced the DataGridToolbar to support optional ReUI filters and improved the overall structure of the KPI stat grid. Updated .gitignore to include .env.local for local environment configurations, ensuring better management of environment variables.
2026-07-17 15:30:41 +07:00
Denozordec aa1779170a refactor: enhance settings and auth components for improved navigation and user feedback
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 54s
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m29s
Updated the Settings and Auth components to include better navigation handling by adding search parameters for tab selection. Enhanced the Access component to utilize badges for status indicators, improving visual clarity. Refactored the Schedule component to streamline job status display with badges, ensuring a more cohesive user experience. Additionally, integrated client directives in UI components for better performance.
2026-07-09 23:39:10 +07:00
Denozordec bbcf9d76d9 refactor: enhance schedule components with improved filtering and layout
CI / changes (push) Successful in 11s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 51s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the ScheduleAgendaPanel to include a job filter feature, allowing users to filter jobs by type (all, refresh, failed). Refactored the layout to integrate a new ScheduleCalendarView for better organization. Enhanced the ScheduleJobsGrid to conditionally display pagination based on the number of items. Additionally, modified the Schedule component to utilize the new ScheduleJobsCard for improved job display and loading states, ensuring a more cohesive user experience.
2026-07-09 23:10:20 +07:00
Denozordec 55eb2a6c89 refactor: enhance dashboard layout and settings components for improved user experience
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the AnalyticsDashboardSkeleton to utilize a new grid layout for KPI tiles, increasing the number of displayed items and adjusting their size for better visibility. Modified the SettingsSettingField component to accept a ReactNode for the description, allowing for richer content. Refactored the Settings component to improve the organization of API token management and session status display, enhancing clarity and usability. Additionally, integrated new UI elements for theme selection and improved layout consistency across components.
2026-07-09 21:58:39 +07:00
Denozordec a3f3ffd672 refactor: integrate PanelCard and enhance dashboard components for improved layout
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m7s
Refactored multiple dashboard components to utilize the new PanelCard for better organization and presentation. Updated the DashboardFramePanel, DashboardQuickLinks, and DashboardOperationsBreakdown components to streamline layouts and enhance user experience. Removed deprecated components and improved loading states in various sections, ensuring a cohesive interface throughout the application.
2026-07-09 21:39:20 +07:00
Denozordec 1a142e68a9 refactor: update Dashboard components for improved layout and integration
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m20s
Refactored the DashboardQuickLinkCard to enhance its structure and presentation by integrating FramePanel. Updated the DashboardQuickLinks component to utilize Frame for better organization, including a header and description. Removed the previous DashboardFramePanel integration in the DashboardComponent to streamline the layout, ensuring a more cohesive user experience.
2026-07-09 21:15:04 +07:00
Denozordec 46e7b2aa6b chore: update BadgeTabs component to log additional layout metrics
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 22s
Added new layout metrics logging to the BadgeTabs component, enhancing debugging capabilities. This update includes additional timestamped entries for layout metrics, providing better insights into component behavior during runtime.
2026-07-09 21:06:33 +07:00
Denozordec 1152bb40f7 feat: enhance BadgeTabs and Auth components with layout improvements and API integration
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 1m14s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m52s
Added layout metrics logging to the BadgeTabs component for better debugging and analysis. Updated the Auth route to include a reason for redirection when a token is required, improving user feedback. Enhanced the Settings component to display an alert when API token access is needed, ensuring clarity for users. Additionally, refined the Tabs component to improve responsiveness based on orientation, contributing to a more cohesive user experience.
2026-07-09 20:54:35 +07:00
Denozordec 538daea0f1 refactor: update dashboard components for improved layout and functionality
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m21s
Refactored the DashboardQuickLinks component to utilize the new DashboardQuickLinkCard for better organization and presentation. Enhanced the DashboardComponent to integrate quick links within a DashboardFramePanel, improving the overall user experience. Updated the metric-tile component import path for consistency. Removed unnecessary imports from the separator component to streamline the codebase.
2026-07-09 18:49:55 +07:00
Denozordec 39f1295438 refactor: update configuration and enhance skeleton components for improved UI consistency
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 4m22s
Modified .npmrc to set a new store directory. Updated eslint configuration to ignore additional paths. Adjusted tsconfig to exclude specific components and refined the SectionCardsSkeleton and AnalyticsDashboardSkeleton for better layout and loading states. Removed the deprecated DashboardQuickActions component to streamline the codebase.
2026-07-09 18:23:53 +07:00
614 changed files with 26720 additions and 27464 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
-6
View File
@@ -1,6 +0,0 @@
{
"pid": 43636,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783570299043
}
+16
View File
@@ -0,0 +1,16 @@
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
+8
View File
@@ -0,0 +1,8 @@
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
@@ -11,9 +11,9 @@
]
},
"reui": {
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
"url": "https://mcp.reui.io",
"headers": {
"X-Reui-Style": "base-nova"
"Authorization": "Bearer <REUI_LICENSE_KEY>"
}
}
}
+29
View File
@@ -0,0 +1,29 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 DNA)
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
## MUST
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
Импорты UI: `@evobgp/ui/components/*`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile
+111
View File
@@ -0,0 +1,111 @@
---
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
alwaysApply: true
---
# ReUI MCP — обязательно (PRO + free)
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
| Документ | URL |
|----------|-----|
| **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started |
| **Styling** | https://reui.io/docs/styling |
| **MCP** | https://reui.io/docs/mcp |
| **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup |
| **Base UI components** | https://reui.io/docs/components/base/<name> |
## Primary MCP
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
## Когда ReUI vs shadcn
| Задача | Registry | Импорт |
|--------|----------|--------|
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
## MCP workflow
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
2. Страница целиком → `compose_page`; секция → `get_block`
3. `get_component` для API primitives из `componentsUsed`
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
6. Adapt by reuse → kit / route
7. `validate_usage` + `get_audit_checklist`
## Размещение
| Слой | Путь | Импорт |
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Эталоны preview
| Зона | Preview |
|------|---------|
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| List | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 |
| Auth | https://reui.io/preview/base/auth-13 |
| Shell | https://reui.io/preview/base/app-shell-12 |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Запрещено
- Копипаст с reui.io без CLI
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
- Radix-варианты docs — только Base UI
- Raw `bg-emerald-*` вместо ReUI `variant`
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
## Чеклист
- [ ] `user-reui` search/get_block + previewUrl
- [ ] `surface: frame`
- [ ] CLI add из `apps/web` при новом item
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
- [ ] `pnpm --filter @evobgp/web run build`
+1 -1
View File
@@ -11,7 +11,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+16 -12
View File
@@ -9,12 +9,12 @@ alwaysApply: false
# Web UI — React + shadcn/ui + ReUI
**Источники правды:**
- **ReUI PRO first:** MCP `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) — pages / KPI / lists / settings / shell
- shadcn primitives: MCP `plugin-shadcn-shadcn` (secondary)
- ReUI Base UI: https://reui.io/docs/components/base/<name> · [llms.txt](https://reui.io/llms.txt)
- shadcn/ui React: https://ui.shadcn.com/docs/components
- ReUI Base UI: https://reui.io/docs/components/base/<name>
- ReUI llms.txt: https://reui.io/llms.txt
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
Иерархия: **ReUI PRO > shadcn**. Общие: `.cursor/rules/engineering.mdc`, `context7-stack.mdc`.
## Слои UI
@@ -22,16 +22,19 @@ alwaysApply: false
|------|------|------------|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
## Правила
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
*Rationale:* единый источник правды и API.
*Проверка:* review; нет самописных примитивов, если есть registry item.
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
*Проверка:* review; [`reui-mcp.mdc`](reui-mcp.mdc).
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
@@ -45,8 +48,8 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
@@ -70,7 +73,7 @@ alwaysApply: false
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
**WEB-15** | MUST | Сомнения — MCP `user-reui` + `plugin-shadcn-shadcn` + docs + `pnpm --filter @evobgp/web run typecheck`.
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
@@ -95,7 +98,8 @@ pnpm --filter @evobgp/web run build
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
*Проверка:* review `queries/*.ts`.
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
---
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
+28
View File
@@ -0,0 +1,28 @@
## Summary
<!-- 13 bullets: what and why -->
## Checklist
### General
- [ ] Docs updated if behavior/API changed (DOC-02)
- [ ] Conventional Commits (EN title / RU body)
### Backend (if Go / OpenAPI / migrations)
- [ ] `go test ./... -race -count=1` (or scoped packages) when touching jobs/pipeline/httpapi
- [ ] OpenAPI lint + regenerate `apps/web/src/types/api.gen.ts` if `docs/openapi.yaml` changed
- [ ] Migration pairs postgres+sqlite (DEP-03)
### Frontend (if `apps/web` / `packages/ui`) — WEB-19
- [ ] `pnpm --filter @evobgp/web run typecheck`
- [ ] `pnpm --filter @evobgp/web run lint`
- [ ] `pnpm --filter @evobgp/web run build`
- [ ] `pnpm --filter @evobgp/web run test`
- [ ] UI follows ReUI PRO + `docs/ui-design-contract.md` (surface `frame`, kit; cite previewUrl)
### KPI / Quick Actions
- [ ] If changing `kpi-stat-grid.tsx` / `quick-action-grid.tsx`, note sibling-app sync (vps / CFDM / fw / auth-portal)
+12 -3
View File
@@ -150,15 +150,23 @@ jobs:
# ---------------------------------------------------------------------------
openapi:
needs: [changes]
if: needs.changes.outputs.openapi == 'true'
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: Lint OpenAPI (Redocly)
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
- name: Check OpenAPI→TS codegen is fresh
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
chmod +x scripts/check-openapi-gen.sh
sh scripts/check-openapi-gen.sh
# ---------------------------------------------------------------------------
web:
@@ -172,12 +180,13 @@ jobs:
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: pnpm install, typecheck, lint, build
- name: pnpm install, typecheck, lint, test, build
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run test
pnpm --filter @evobgp/web run build
# ---------------------------------------------------------------------------
+5
View File
@@ -21,9 +21,14 @@ Thumbs.db
# Env / secrets (local only)
.env
.env.local
.env.*
!.env.example
!.env.*.example
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
deploy/compose/runtime-logs/
# Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json
.mcp.json
.codegraph/daemon.pid
-16
View File
@@ -1,16 +0,0 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
},
"reui": {
"type": "http",
"url": "https://mcp.reui.io/api/mcp"
}
}
}
+1 -1
View File
@@ -1 +1 @@
engine-strict=true
store-dir=C:\Users\shats\AppData\Local\pnpm-test-store\store\v10
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
+4 -2
View File
@@ -69,6 +69,8 @@ pnpm --filter @evobgp/web run build
Все три команды должны exit 0. CI job `web` не пропускает без этого.
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte UI удалён (миграция завершена).
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
+28
View File
@@ -0,0 +1,28 @@
# EvoBGP web (Vite) — переменные окружения.
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
# ---------------------------------------------------------------------------
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
# ---------------------------------------------------------------------------
# REUI_LICENSE_KEY=
# ---------------------------------------------------------------------------
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
# недоступен либо VITE_AUTH_ENABLED=false).
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
# ---------------------------------------------------------------------------
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
# ---------------------------------------------------------------------------
# Auth-portal SSO
# ---------------------------------------------------------------------------
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
# Пример:
# VITE_AUTH_ENABLED=true
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
#
# Backend опционально может отдавать GET /v1/auth/config
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
# VITE_AUTH_ENABLED=false
# VITE_AUTH_PORTAL_URL=http://localhost:5175
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test'
/**
* Smoke against local API (+ optional UI). Profile: compose microvps-full.
*
* Env:
* - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080)
* - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173)
* - EVOBGP_E2E_TOKEN (default dev)
*/
const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080'
const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173'
const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev'
test.describe('EvoBGP smoke', () => {
test('API health and modules list', async ({ request }) => {
const health = await request.get(`${apiBase}/v1/health`)
expect(health.ok()).toBeTruthy()
const mods = await request.get(`${apiBase}/v1/modules?limit=10`, {
headers: { Authorization: `Bearer ${token}` },
})
expect([200, 401]).toContain(mods.status())
})
test('UI loads when available', async ({ page }) => {
test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke')
await page.goto(uiBase)
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('body')).toBeVisible()
})
})
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import globals from 'globals'
export default tseslint.config(
{ ignores: ['dist', 'src/routeTree.gen.ts'] },
{ ignores: ['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+9 -4
View File
@@ -9,7 +9,10 @@
"preview": "vite preview",
"typecheck": "tsr generate && tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "playwright test",
"openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts",
"openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
@@ -40,22 +43,24 @@
"zod": "^3.25.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.62.1",
"@tailwindcss/vite": "^4.1.0",
"@tanstack/router-plugin": "^1.130.0",
"@tanstack/router-cli": "^1.130.0",
"@tanstack/router-plugin": "^1.130.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.0.0",
"@eslint/js": "^9.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.0",
"globals": "^15.0.0",
"typescript-eslint": "^8.0.0",
"happy-dom": "^18.0.0",
"openapi-typescript": "^7.13.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.0.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.0.0",
"vite": "^7.3.1",
"vitest": "^3.0.0"
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173',
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})
@@ -4,7 +4,7 @@ import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
import { DataGridCard } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { QueryState } from '@/components/query-state'
@@ -47,7 +47,7 @@ export function AccessApiKeysCard({
return (
<>
<DataGridCard
<FrameDataGrid
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
actions={
@@ -85,7 +85,7 @@ export function AccessApiKeysCard({
/>
)}
</QueryState>
</DataGridCard>
</FrameDataGrid>
<ApiKeyCreateDialog
open={createOpen}
@@ -1,5 +1,5 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
export type AnalyticsKpiItem = {
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
if (direction === 'up') return <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
function deltaBadgeVariant(
tone?: 'success' | 'warning' | 'destructive' | 'muted',
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
if (tone === 'success') return 'success-light'
if (tone === 'warning') return 'warning-light'
if (tone === 'destructive') return 'destructive-light'
return 'outline'
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
return {
id: item.label,
value: item.value,
label: item.label,
footer: item.delta ? (
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
{item.delta.label}
</Badge>
) : undefined,
}
}
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
export function AnalyticsKpiRow({
items,
className,
}: {
items: AnalyticsKpiItem[]
className?: string
}) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
<KpiStatGrid
items={items.map(toKpiItem)}
embedded
className={cn(className)}
aria-label="Показатели"
/>
)
}
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
export { DashboardPlatformCard } from './dashboard-platform-card'
export { MonitoringHealthCard } from './monitoring-health-card'
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
export { OperationsAnalyticsCard } from './operations-analytics-card'
@@ -1,5 +1,4 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
import { readinessBreakdown } from '@/lib/metrics'
import type { ReadyStatus } from '@/queries/monitoring'
@@ -13,21 +12,25 @@ export function MonitoringHealthCard({
loading?: boolean
}) {
const slices = readinessBreakdown(ready, healthOk)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
if (loading) {
return (
<DonutBreakdownCard
title="Доступность системы"
description="Проверки живучести и готовности"
slices={[]}
centerLabel="Проверки"
/>
)
}
return (
<AnalyticsCardShell
<DonutBreakdownCard
title="Доступность системы"
description="Проверки живучести и готовности"
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
)}
</AnalyticsCardShell>
description="GET /v1/health · GET /v1/ready"
slices={slices}
centerLabel="Проверки"
badge={healthOk ? 'API OK' : undefined}
/>
)
}
@@ -1,6 +1,5 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
import { peerSessionBreakdown } from '@/lib/metrics'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
@@ -16,51 +15,44 @@ export function NetworkOverviewAnalyticsCard({
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const speakersPct =
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
if (loading) {
return (
<DonutBreakdownCard
title="Сводка BGP"
description="Установленные сессии и спикеры"
slices={[]}
centerLabel="Пиры"
/>
)
}
return (
<AnalyticsCardShell
title="Сводка BGP"
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры с установленной сессией',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success',
},
},
{
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
delta: {
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
label:
net.speakersOnline < net.speakersTotal
? `${net.speakersTotal - net.speakersOnline} не в сети`
: 'все в сети',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
},
},
{
label: 'Пиры всего',
value: loading ? '—' : String(net.peersTotal),
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
},
]}
<div className="grid gap-4 lg:grid-cols-2">
<DonutBreakdownCard
title="Сводка BGP"
description="Распределение состояний пиров"
slices={slices}
centerLabel="Пиры"
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
<SegmentedProgressCard
title="Спикеры"
description="Доступность live-агентов"
primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`,
label: 'Online',
percent: speakersPct,
}}
secondary={{
value: net.speakersTotal - net.speakersOnline,
label: 'Offline',
percent: 100 - speakersPct,
}}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
/>
</div>
)
}
@@ -1,65 +0,0 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown } from '@/lib/metrics'
import type { JobRow, RevisionRow } from '@/types/api'
export function OperationsAnalyticsCard({
jobs,
revisions,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const slices = jobStatusBreakdown(jobs)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Операции и задачи"
description="Статистика ревизий и фоновых jobs"
info="Данные из GET /v1/jobs и /v1/revisions."
>
<AnalyticsKpiRow
items={[
{
label: 'Ревизий',
value: loading ? '—' : String(revisions.length),
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
},
{
label: 'Активных задач',
value: loading ? '—' : String(running),
delta: {
direction: running > 0 ? 'up' : 'neutral',
label: running > 0 ? 'выполняются' : 'очередь пуста',
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
},
},
{
label: 'С ошибкой',
value: loading ? '—' : String(failed),
delta: {
direction: failed > 0 ? 'down' : 'up',
label: failed > 0 ? 'требуют внимания' : 'в норме',
tone: failed > 0 ? 'destructive' : 'success',
},
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
+97
View File
@@ -0,0 +1,97 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem key={app.id} disabled>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+2 -4
View File
@@ -40,14 +40,12 @@ export function BadgeTabs({
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation="horizontal"
className={cn('w-full', className)}
>
<TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
@@ -0,0 +1,116 @@
"use client"
import { useState, type ComponentType } from "react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@evobgp/ui/lib/utils"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { BillingTab } from "./billing-tab"
import { SETTINGS_TAB_ITEMS } from "./data"
import { NotificationsTab } from "./notifications-tab"
import { ProfileTab } from "./profile-tab"
import { SecurityTab } from "./security-tab"
const TAB_COMPONENTS: Record<string, ComponentType> = {
profile: ProfileTab,
security: SecurityTab,
notifications: NotificationsTab,
billing: BillingTab,
}
// ── Settings Navigation ──
function SettingsNavigation({
isMobile,
activeValue,
}: {
isMobile: boolean
activeValue: string
}) {
return (
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
{isMobile ? (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
</div>
) : (
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
)}
</div>
)
}
export function AccountSettings() {
const isMobile = useIsMobile()
const [activeTab, setActiveTab] = useState("profile")
return (
<div className="w-full max-w-4xl space-y-8">
{/* Header */}
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">
Account Settings
</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
Update your profile, access, notifications, and billing preferences.
</p>
</header>
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation={isMobile ? "horizontal" : "vertical"}
className={cn("w-full gap-4 lg:gap-8")}
>
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
<div className="min-w-0 flex-1">
{SETTINGS_TAB_ITEMS.map((tab) => {
const TabComponent = TAB_COMPONENTS[tab.value]
return (
<TabsContent key={tab.value} value={tab.value} className="mt-0">
<TabComponent />
</TabsContent>
)
})}
</div>
</Tabs>
</div>
)
}
@@ -0,0 +1,180 @@
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { BILLING_PLANS, COUNTRIES } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function BillingTab() {
const [country, setCountry] = useState("us")
const handleCountryChange = createSelectValueHandler(setCountry)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Plan and billing" description="Current subscription">
<SettingsFieldGroup
legend="Plan and billing"
description="Review your current subscription and plan options."
>
{BILLING_PLANS.map((plan, index) => (
<SettingRow
key={plan.id}
title={plan.name}
titleAddon={
plan.current ? (
<Badge variant="info-light" size="sm">
Current
</Badge>
) : null
}
description={
<>
<span className="text-foreground font-medium">
{plan.price}
</span>{" "}
/ {plan.period} · {plan.features.join(", ")}
</>
}
last={index === BILLING_PLANS.length - 1}
>
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
{plan.current ? "Manage" : "Switch"}
</Button>
</SettingRow>
))}
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Billing details"
description="Invoices and payments"
footer={<Button>Update billing</Button>}
>
<SettingsFieldGroup
legend="Billing details"
description="Manage invoice contacts and payment details."
>
<SettingRow
title="Billing email"
description="Receives invoices and renewal notices."
labelFor="settings-7-billing-email"
>
<Input
id="settings-7-billing-email"
defaultValue="[email protected]"
type="email"
/>
</SettingRow>
<SettingRow
title="Invoice profile"
description="Business details used on receipts and tax forms."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
<FieldDescription className="sr-only">
Company identity used for invoices and tax documents.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-company-name">
Company name
</FieldLabel>
<Input
id="settings-7-company-name"
defaultValue="Acme Labs"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-billing-country">
Country
</FieldLabel>
<Select value={country} onValueChange={handleCountryChange}>
<SelectTrigger
id="settings-7-billing-country"
className="w-full"
>
<SelectValue>
{getOptionLabel(COUNTRIES, country)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COUNTRIES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Tax ID"
description="Optional number used for VAT or company invoices."
labelFor="settings-7-tax-id"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>VAT</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-tax-id"
defaultValue="US-2048-ACME"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Card on file"
description="Used for monthly renewals."
last
>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-muted-foreground text-sm">
Visa ending in 4242
</span>
<Button variant="outline">Update</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,187 @@
"use client"
import { type ReactNode } from "react"
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type BillingPlan = {
id: string
name: string
price: string
period: string
current: boolean
features: string[]
}
export type Session = {
id: string
device: string
browser: string
location: string
lastActive: string
current: boolean
icon: ReactNode
}
export type SettingsTabItem = {
value: string
label: string
icon: ReactNode
}
// ── Data ──
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
{
value: "profile",
label: "My Profile",
icon: (
<UserIcon aria-hidden="true" />
),
},
{
value: "security",
label: "Security",
icon: (
<ShieldIcon aria-hidden="true" />
),
},
{
value: "notifications",
label: "Notifications",
icon: (
<BellIcon aria-hidden="true" />
),
},
{
value: "billing",
label: "Billing",
icon: (
<CreditCardIcon aria-hidden="true" />
),
},
]
export const TIMEZONES: SelectOption[] = [
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
{ value: "utc+0", label: "UTC+0 (London)" },
{ value: "utc+1", label: "UTC+1 (Berlin)" },
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
]
export const ROLES: SelectOption[] = [
{ value: "engineering-lead", label: "Engineering Lead" },
{ value: "developer", label: "Developer" },
{ value: "designer", label: "Designer" },
{ value: "product-manager", label: "Product Manager" },
]
export const COUNTRIES: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "uz", label: "Uzbekistan" },
]
export const TIMEOUTS: SelectOption[] = [
{ value: "5", label: "5 minutes" },
{ value: "10", label: "10 minutes" },
{ value: "15", label: "15 minutes" },
{ value: "30", label: "30 minutes" },
]
export const QUIET_HOURS: SelectOption[] = [
{ value: "18:00", label: "6:00 PM" },
{ value: "20:00", label: "8:00 PM" },
{ value: "22:00", label: "10:00 PM" },
{ value: "23:00", label: "11:00 PM" },
]
export const QUIET_HOURS_END: SelectOption[] = [
{ value: "06:00", label: "6:00 AM" },
{ value: "08:00", label: "8:00 AM" },
{ value: "09:00", label: "9:00 AM" },
{ value: "10:00", label: "10:00 AM" },
]
export const DIGEST_CADENCE: SelectOption[] = [
{ value: "daily", label: "Daily summary" },
{ value: "weekly", label: "Weekly digest" },
{ value: "mentions", label: "Only mentions" },
]
export const RECOVERY_METHODS: SelectOption[] = [
{ value: "authenticator", label: "Authenticator first" },
{ value: "sms", label: "SMS fallback" },
{ value: "email", label: "Email fallback" },
]
export const BILLING_PLANS: BillingPlan[] = [
{
id: "free",
name: "Free",
price: "$0",
period: "forever",
current: false,
features: ["1 workspace", "Basic exports", "Community support"],
},
{
id: "pro",
name: "Pro",
price: "$29",
period: "per month",
current: true,
features: ["Unlimited workspaces", "Automations", "Priority support"],
},
{
id: "team",
name: "Team",
price: "$79",
period: "per month",
current: false,
features: ["Everything in Pro", "SSO", "Shared billing"],
},
]
export const SESSIONS: Session[] = [
{
id: "sess-1",
device: "macOS",
browser: "Chrome",
location: "San Francisco, CA",
lastActive: "Active now",
current: true,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
{
id: "sess-2",
device: "iPhone",
browser: "Safari",
location: "San Francisco, CA",
lastActive: "2 hours ago",
current: false,
icon: (
<SmartphoneIcon aria-hidden="true" />
),
},
{
id: "sess-3",
device: "Windows",
browser: "Firefox",
location: "New York, NY",
lastActive: "3 days ago",
current: false,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
]
@@ -0,0 +1,223 @@
import { useState } from "react"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function NotificationsTab() {
const [autoDismiss, setAutoDismiss] = useState("10")
const [quietStart, setQuietStart] = useState("20:00")
const [quietEnd, setQuietEnd] = useState("08:00")
const [digestCadence, setDigestCadence] = useState("weekly")
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Activity alerts" description="Desktop and badge">
<SettingsFieldGroup
legend="Activity alerts"
description="Control desktop, badge, and sound alerts."
>
<SettingRow
title="Desktop notifications"
description="Show alerts for mentions and approvals."
labelFor="settings-7-desktop-notifications"
>
<Switch id="settings-7-desktop-notifications" defaultChecked />
</SettingRow>
<SettingRow
title="Quiet hours"
description="Hold non-urgent alerts outside your workday."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
<FieldDescription className="sr-only">
Define when non-urgent activity should stay muted.
</FieldDescription>
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="settings-7-quiet-start">
Start
</FieldLabel>
<Select
value={quietStart}
onValueChange={handleQuietStartChange}
>
<SelectTrigger
id="settings-7-quiet-start"
className="w-full"
>
<SelectValue>
{getOptionLabel(QUIET_HOURS, quietStart)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
<SelectTrigger id="settings-7-quiet-end" className="w-full">
<SelectValue>
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS_END.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Unread badge"
description="Display a badge when new activity arrives."
labelFor="settings-7-unread-badge"
>
<Switch id="settings-7-unread-badge" defaultChecked />
</SettingRow>
<SettingRow
title="Auto dismiss"
description="Choose how long alerts stay visible."
labelFor="settings-7-auto-dismiss"
last
>
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
<SelectValue>
{getOptionLabel(TIMEOUTS, autoDismiss)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEOUTS.map((timeout) => (
<SelectItem key={timeout.value} value={timeout.value}>
{timeout.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Email updates"
description="Inbox preferences"
footer={
<>
<Button variant="outline">Reset</Button>
<Button>Save preferences</Button>
</>
}
>
<SettingsFieldGroup
legend="Email updates"
description="Control which emails reach your inbox."
>
<SettingRow
title="Team communication"
description="Messages, approvals, and activity summaries."
labelFor="settings-7-team-communication"
>
<Switch id="settings-7-team-communication" defaultChecked />
</SettingRow>
<SettingRow
title="Product announcements"
description="Releases, improvements, and launches."
labelFor="settings-7-product-announcements"
>
<Switch id="settings-7-product-announcements" />
</SettingRow>
<SettingRow
title="Weekly digest"
description="A recap of workspaces, mentions, and tasks."
labelFor="settings-7-weekly-digest"
>
<Switch id="settings-7-weekly-digest" defaultChecked />
</SettingRow>
<SettingRow
title="Digest cadence"
description="Set how often summary emails arrive."
labelFor="settings-7-digest-cadence"
last
>
<Select
value={digestCadence}
onValueChange={handleDigestCadenceChange}
>
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
<SelectValue>
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DIGEST_CADENCE.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,103 @@
"use client"
import { useState } from "react"
import { useFileUpload } from "@/hooks/use-file-upload"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
interface ProfileAvatarUploadProps {
defaultAvatar: string
alt: string
inputId?: string
}
export function ProfileAvatarUpload({
defaultAvatar,
alt,
inputId,
}: ProfileAvatarUploadProps) {
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
useFileUpload({
accept: "image/*",
})
const currentFile = files[0] ?? null
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
const previewUrl =
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
const hasPhoto = Boolean(previewUrl)
const handleCancelUpload = () => {
if (!currentFile) {
return
}
removeFile(currentFile.id)
}
const handleRemovePhoto = () => {
if (currentFile) {
removeFile(currentFile.id)
}
setRemovedCurrentPhoto(true)
}
return (
<div className="flex grow flex-wrap items-center justify-start gap-2">
{/* Actions */}
<div className="relative">
<Avatar className="size-10">
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
<AvatarFallback className="text-muted-foreground bg-muted">
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
</AvatarFallback>
</Avatar>
{currentFile ? (
<Button
variant="outline"
size="icon-xs"
onClick={handleCancelUpload}
className="absolute -top-1 -right-1 size-4 rounded-full"
aria-label={`Cancel ${currentFile.file.name}`}
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
<div className="relative inline-flex">
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
aria-haspopup="dialog"
>
<UploadIcon aria-hidden="true" />
{hasPhoto ? "Change" : "Upload"}
</Button>
<input
{...getInputProps({ id: inputId })}
className="sr-only"
aria-label="Upload profile image"
tabIndex={-1}
/>
</div>
{hasPhoto ? (
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
<XIcon aria-hidden="true" />
Remove
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,217 @@
import { useState } from "react"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Textarea } from "@evobgp/ui/components/textarea"
import { ROLES, TIMEZONES } from "./data"
import { ProfileAvatarUpload } from "./profile-avatar-upload"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { UserIcon } from "lucide-react"
export function ProfileTab() {
const [role, setRole] = useState("engineering-lead")
const [timezone, setTimezone] = useState("utc-8")
const handleRoleChange = createSelectValueHandler(setRole)
const handleTimezoneChange = createSelectValueHandler(setTimezone)
return (
<div className="space-y-6">
<Alert variant="warning">
<UserIcon aria-hidden="true" />
<AlertTitle>Complete your profile.</AlertTitle>
<AlertDescription>
Add a photo and keep your role and timezone current.
</AlertDescription>
<AlertAction>
<Button type="button" variant="outline" size="xs">
Dismiss
</Button>
<Button type="button" size="xs">
Update
</Button>
</AlertAction>
</Alert>
{/* Card */}
<SettingsCard
title="My profile"
description="Public account details"
footer={
<>
<Button variant="outline">Cancel</Button>
<Button>Save changes</Button>
</>
}
>
<SettingsFieldGroup
legend="Profile fields"
description="Update your personal profile details."
>
<SettingRow
title="Photo"
description="Shown in comments and mentions."
>
<ProfileAvatarUpload
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
alt="Alex Morgan"
inputId="settings-7-profile-photo"
/>
</SettingRow>
<SettingRow
title="Full name"
description="Used across the workspace."
labelFor="settings-7-full-name"
>
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
</SettingRow>
<SettingRow
title="Email address"
description="Primary sign-in email."
labelFor="settings-7-email"
titleAddon={<Badge variant="success-light">Verified</Badge>}
>
<Input
id="settings-7-email"
defaultValue="[email protected]"
type="email"
/>
</SettingRow>
<SettingRow
title="Username"
description="Visible in mentions and links."
labelFor="settings-7-username"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>@</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-username"
defaultValue="alexmorgan"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Profile details"
description="Public details shared across the workspace."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Profile details</FieldLegend>
<FieldDescription className="sr-only">
Public profile and workspace defaults.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
<Select value={role} onValueChange={handleRoleChange}>
<SelectTrigger id="settings-7-role" className="w-full">
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-timezone">
Time zone
</FieldLabel>
<Select value={timezone} onValueChange={handleTimezoneChange}>
<SelectTrigger id="settings-7-timezone" className="w-full">
<SelectValue>
{getOptionLabel(TIMEZONES, timezone)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEZONES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-website"
defaultValue="alexmorgan.dev"
/>
</InputGroup>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Bio"
description="Short profile summary."
labelFor="settings-7-bio"
last
>
<Textarea
id="settings-7-bio"
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
rows={4}
className="min-h-24 resize-none"
/>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,257 @@
"use client"
import { Fragment, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@evobgp/ui/components/item"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { RECOVERY_METHODS, SESSIONS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { LogOutIcon } from "lucide-react"
export function SecurityTab() {
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard
title="Change password"
description="Access protection"
footer={<Button>Update password</Button>}
>
<SettingsFieldGroup
legend="Password settings"
description="Update your password and keep your account protected."
>
<SettingRow
title="Current password"
description="Verify your identity before making changes."
labelFor="settings-7-current-password"
>
<Input
id="settings-7-current-password"
type="password"
placeholder="Enter current password"
/>
</SettingRow>
<SettingRow
title="New password"
description="Use at least 12 characters and a unique phrase."
contentClassName="@md/field-group:w-[22rem]"
last
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">New password</FieldLegend>
<FieldDescription className="sr-only">
Create and confirm the next password for this account.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-new-password">
New password
</FieldLabel>
<Input
id="settings-7-new-password"
type="password"
placeholder="Create a new password"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-confirm-password">
Confirm password
</FieldLabel>
<Input
id="settings-7-confirm-password"
type="password"
placeholder="Confirm the new password"
/>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Two-step verification"
description="Extra sign-in checks"
>
<SettingsFieldGroup
legend="Two-step verification"
description="Add backup checks for future sign-ins."
>
<SettingRow
title="Authenticator app"
description="Use one-time codes from an app."
labelFor="settings-7-authenticator-app"
titleAddon={
<Badge variant="info-light" size="sm">
Recommended
</Badge>
}
>
<Switch id="settings-7-authenticator-app" defaultChecked />
</SettingRow>
<SettingRow
title="Recovery phone"
description="Used if you lose access to your authenticator app."
labelFor="settings-7-recovery-phone"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>+1</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-recovery-phone"
defaultValue="415 555 0148"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Delivery method"
description="Choose how backup verification requests are delivered."
labelFor="settings-7-recovery-method"
>
<Select
value={recoveryMethod}
onValueChange={handleRecoveryMethodChange}
>
<SelectTrigger id="settings-7-recovery-method" className="w-full">
<SelectValue>
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{RECOVERY_METHODS.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
<SettingRow
title="Trusted devices"
description="Skip repeat prompts on known devices."
labelFor="settings-7-trusted-devices"
last
>
<Switch id="settings-7-trusted-devices" defaultChecked />
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard title="Active sessions" description="Signed-in devices">
<ItemGroup className="gap-0">
{SESSIONS.map((session, index) => (
<Fragment key={session.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{session.icon}
</Item>
</ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5">
{session.browser} on {session.device}
{session.current ? (
<Badge variant="success-light" size="xs">
Current
</Badge>
) : null}
</ItemTitle>
<ItemDescription className="leading-5">
{session.location} · {session.lastActive}
</ItemDescription>
</ItemContent>
<ItemActions className="w-28 shrink-0 justify-end self-center">
{session.current ? (
<Button variant="outline" size="sm">
This device
</Button>
) : (
<Button variant="ghost" size="sm">
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
Revoke
</Button>
)}
</ItemActions>
</Item>
</Fragment>
))}
</ItemGroup>
</SettingsCard>
<SettingsCard title="Delete account" description="Irreversible changes">
<SettingsFieldGroup
legend="Delete account"
description="Review irreversible actions before deleting your account."
>
<SettingRow
title="Close account"
description="Permanently remove your account, sessions, and recovery settings."
last
contentClassName="@md/field-group:w-[22rem]"
>
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
<p className="text-muted-foreground text-xs leading-5">
This action cannot be undone.
</p>
<Button variant="destructive">Delete account</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,62 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@evobgp/ui/components/field"
interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
titleAddon?: ReactNode
}
export function SettingRow({
title,
description,
children,
last,
labelFor,
contentClassName,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-5 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
<div className="flex w-full justify-start @md/field-group:justify-end">
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,37 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
interface SettingsFieldGroupProps {
legend: string
description: string
children: ReactNode
className?: string
fieldGroupClassName?: string
}
export function SettingsFieldGroup({
legend,
description,
children,
className,
fieldGroupClassName,
}: SettingsFieldGroupProps) {
return (
<FieldSet className={cn("gap-0", className)}>
<FieldLegend className="sr-only">{legend}</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">{description}</FieldDescription>
{/* List */}
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
{children}
</FieldGroup>
</FieldSet>
)
}
@@ -0,0 +1,22 @@
"use client"
import { type Dispatch, type SetStateAction } from "react"
import type { SelectOption } from "./data"
export function getOptionLabel<T extends SelectOption>(
options: T[],
value: string
) {
return options.find((option) => option.value === value)?.label ?? value
}
export function createSelectValueHandler(
setValue: Dispatch<SetStateAction<string>>
) {
return (value: string | null) => {
if (value !== null) {
setValue(value)
}
}
}
@@ -0,0 +1,9 @@
import { AccountSettings } from "./components/account-settings"
export function Page() {
return (
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
<AccountSettings />
</div>
)
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -0,0 +1,87 @@
'use client'
import { useEffect, useRef } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
function grain(i: number, j: number) {
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
return n - Math.floor(n)
}
/**
* Static dot field adapted from REUI card-17: theme-aware canvas dots,
* repainted on resize and theme change.
*/
export function CardDotField({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const GAP = 3
const DOT = 1.5
const BASE = 0.05
const PEAK = 0.32
const draw = () => {
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
ctx.fillStyle = getComputedStyle(canvas).color || 'rgb(115,115,115)'
ctx.fillRect(0, 0, 1, 1)
const px = ctx.getImageData(0, 0, 1, 1).data
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, rect.width, rect.height)
ctx.fillStyle = color
const cols = Math.ceil(rect.width / GAP) + 1
const rows = Math.ceil(rect.height / GAP) + 1
for (let i = 0; i < cols; i++) {
const x = i * GAP
for (let j = 0; j < rows; j++) {
const q = grain(i, j)
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
let a = (BASE + (PEAK - BASE) * q * q) * amp
if (a > 1) a = 1
ctx.globalAlpha = a
ctx.fillRect(x, j * GAP, DOT, DOT)
}
}
ctx.globalAlpha = 1
}
draw()
const resizeObserver = new ResizeObserver(() => draw())
resizeObserver.observe(canvas)
const themeObserver = new MutationObserver(() => draw())
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style'],
})
return () => {
resizeObserver.disconnect()
themeObserver.disconnect()
}
}, [])
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn('pointer-events-none absolute inset-0 h-full w-full', className)}
/>
)
}
@@ -0,0 +1,102 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import { cn } from '@evobgp/ui/lib/utils'
import { recentPlatformActivity } from '@/lib/metrics'
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
const KIND_META = {
job: { icon: Info, className: 'text-info' },
revision: { icon: CheckCircle, className: 'text-success' },
network: { icon: AlertTriangle, className: 'text-warning' },
} as const
function statusBadgeVariant(status: string) {
const s = status.toLowerCase()
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
return 'outline' as const
}
export function DashboardActivityTimeline({
jobs,
revisions,
peers,
speakers,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
return (
<DashboardFramePanel
title="Недавняя активность"
description="Задачи, ревизии и сетевые события"
className="h-full"
>
{loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
) : items.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
) : (
<div className="px-4 py-4">
<Timeline defaultValue={items.length}>
{items.map((item, index) => {
const meta = KIND_META[item.kind]
const Icon = meta.icon
return (
<TimelineItem
key={item.id}
step={index + 1}
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
>
<TimelineHeader>
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
<span
className={cn(
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
meta.className,
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal">
{item.message}
</TimelineTitle>
<div className="mt-2">
<Badge variant={statusBadgeVariant(item.status)} size="sm">
{item.statusLabel ?? item.status}
</Badge>
</div>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
</div>
)}
</DashboardFramePanel>
)
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import {
PanelCard,
panelCardContentFlushClassName,
} from '@/components/panel-card'
import { cn } from '@evobgp/ui/lib/utils'
/** Card-surface panel for dashboard sections (replaces legacy Frame shell). */
export function DashboardFramePanel({
title,
description,
actions,
children,
className,
contentClassName,
}: {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}) {
return (
<PanelCard
title={title}
description={description}
actions={actions}
className={cn('h-full', className)}
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
>
{children}
</PanelCard>
)
}
@@ -0,0 +1,177 @@
import {
AlertTriangle,
Boxes,
ListChecks,
Network,
ServerCog,
Share2,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
type KpiCard = KpiStatItem & { icon: ReactNode }
function buildKpis({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}): KpiCard[] {
const enabledModules = modules.filter((m) => m.enabled !== false).length
const network = aggregateNetworkMetrics(peers, speakers)
const peersEnabled = network.peersEnabled
const bgpPct =
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
const running = runningJobCount(jobs)
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
return [
{
id: 'modules',
icon: <Boxes aria-hidden />,
iconClassName: 'text-primary',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
label: 'Модули активны',
footer: (
<Badge variant="primary-light" size="sm">
{loading ? '…' : `${modules.length} всего`}
</Badge>
),
},
{
id: 'bgp',
icon: <Network aria-hidden />,
iconClassName: 'text-info',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
label: 'BGP готовность',
footer: (
<Badge
variant={
bgpPct !== null && bgpPct >= 90
? 'success-light'
: bgpPct !== null && bgpPct < 70
? 'warning-light'
: 'outline'
}
size="sm"
>
{loading || bgpPct === null
? 'нет включённых пиров'
: `${network.peersEstablished} установлено`}
</Badge>
),
},
{
id: 'peers',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры Established',
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${network.peersTotal} в каталоге`}
</Badge>
),
},
{
id: 'speakers',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры online',
footer: (
<Badge
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm"
>
{loading ? '…' : 'live-снимок'}
</Badge>
),
},
{
id: 'jobs',
icon: <ListChecks aria-hidden />,
iconClassName: 'text-focus',
value: loading ? '—' : String(running),
label: 'Активные задачи',
footer: (
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
{loading ? '…' : `${jobs.length} в выборке`}
</Badge>
),
},
{
id: 'risks',
icon: <AlertTriangle aria-hidden />,
iconClassName: 'text-destructive',
value: loading ? '—' : String(riskCount),
label: 'Риски',
variant: riskCount > 0 ? 'destructive' : 'default',
footer: (
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
{loading
? '…'
: riskCount > 0
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
: 'в норме'}
</Badge>
),
},
]
}
export function buildDashboardKpiCards({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
return buildKpis({ modules, peers, speakers, jobs, loading }).map((card) => ({
...card,
id: card.id ?? String(card.label),
}))
}
export function DashboardKpiGrid({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
return (
<KpiStatGrid
items={buildKpis({ modules, peers, speakers, jobs, loading })}
aria-label="KPI обзора"
/>
)
}
@@ -0,0 +1,118 @@
import { useMemo } from 'react'
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
import { kpiGridClassName } from '@/lib/ui-surface'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
function syntheticSparkline(seed: number, points = 9): number[] {
const base = Math.max(4, seed)
return Array.from({ length: points }, (_, i) =>
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
)
}
function buildMetrics({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}): KpiSparklineMetric[] {
const enabledModules = modules.filter((m) => m.enabled !== false).length
const network = aggregateNetworkMetrics(peers, speakers)
const bgpPct =
network.peersEnabled > 0
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
: 0
const running = runningJobCount(jobs)
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
return [
{
id: 'bgp',
title: 'BGP готовность',
label: 'Established / включённые',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
detail: loading ? '' : `${network.peersEstablished} сессий`,
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
sparkline: syntheticSparkline(bgpPct || 40),
},
{
id: 'modules',
title: 'Модули',
label: 'Активные списки',
value: loading ? '—' : `${enabledModules}`,
delta: loading ? '…' : `${modules.length} всего`,
deltaVariant: 'primary-light',
detail: loading ? '' : 'маршрутизация',
tone: 'info',
sparkline: syntheticSparkline(enabledModules || 3),
},
{
id: 'speakers',
title: 'Спикеры',
label: 'Online / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta:
loading || network.speakersTotal === 0
? '…'
: network.speakersOnline === network.speakersTotal
? 'все online'
: 'частично',
deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
detail: loading ? '' : 'live-снимок',
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
sparkline: syntheticSparkline(network.speakersOnline || 2),
},
{
id: 'jobs',
title: 'Задачи',
label: 'Активные / ошибки',
value: loading ? '—' : String(running),
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
detail: loading ? '' : `${jobs.length} в выборке`,
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
sparkline: syntheticSparkline(running + failedJobs || 1),
},
]
}
export function DashboardKpiSparklineRow({
modules,
peers,
speakers,
jobs,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const metrics = useMemo(
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
[modules, peers, speakers, jobs, loading],
)
return (
<section aria-label="KPI обзора" className={kpiGridClassName}>
{metrics.map((metric) => (
<KpiSparklineCard key={metric.id} metric={metric} />
))}
</section>
)
}
@@ -0,0 +1,352 @@
import { ColumnDef } from '@tanstack/react-table'
import { Link, useNavigate } from '@tanstack/react-router'
import {
ArrowUpDownIcon,
BoxesIcon,
ChevronDownIcon,
FilterIcon,
PlusIcon,
SearchIcon,
XIcon,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
DataGridTable,
DataGridTableHeader,
} from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type SortingState,
} from '@tanstack/react-table'
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
type EnabledFilter = 'all' | 'enabled' | 'disabled'
const sortLabels: Record<ModuleSort, string> = {
name: 'Название',
type: 'Тип',
priority: 'Приоритет',
last_refreshed_at: 'Обновлено',
}
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
}
export function DashboardModulesGrid({
modules,
isLoading = false,
}: {
modules: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState('')
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>('all')
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sortBy, setSortBy] = useState<ModuleSort>('name')
const [sorting, setSorting] = useState<SortingState>(() => buildSorting('name'))
const filteredModules = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
return modules.filter((module) => {
const matchesSearch =
q.length === 0 ||
`${module.name} ${module.type} ${moduleTypeRu(module.type)}`.toLowerCase().includes(q)
const matchesEnabled =
enabledFilter === 'all' ||
(enabledFilter === 'enabled' ? module.enabled !== false : module.enabled === false)
return matchesSearch && matchesEnabled
})
}, [modules, searchQuery, enabledFilter])
const resetPagination = useCallback(() => {
setPagination((current) => ({ ...current, pageIndex: 0 }))
}, [])
const handleSortChange = useCallback(
(value: ModuleSort) => {
setSortBy(value)
setSorting(buildSorting(value))
resetPagination()
},
[resetPagination],
)
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
id: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" />
</div>
),
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'type',
id: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
id: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
meta: { headerTitle: 'Приоритет' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) =>
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
const table = useReactTable({
data: filteredModules,
columns,
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const activeFilterCount = enabledFilter === 'all' ? 0 : 1
return (
<DashboardFramePanel
title="Модули"
description="Поиск, сортировка и быстрый переход к настройке"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<PlusIcon />
Создать
</Button>
}
>
<DataGrid
table={table}
recordCount={filteredModules.length}
isLoading={isLoading}
emptyMessage={EMPTY_MESSAGE}
tableLayout={{
dense: true,
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: 'fixed',
}}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
>
<div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<InputGroup className="w-full min-w-40 sm:max-w-xs">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
resetPagination()
}}
placeholder="Поиск модулей…"
aria-label="Поиск модулей"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Очистить поиск"
onClick={() => {
setSearchQuery('')
resetPagination()
}}
>
<XIcon className="size-4" aria-hidden />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<div className="flex flex-wrap items-center gap-2">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="sm">
<ArrowUpDownIcon data-icon="inline-start" aria-hidden />
{sortLabels[sortBy]}
<ChevronDownIcon data-icon="inline-end" aria-hidden />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuGroup>
{(Object.keys(sortLabels) as ModuleSort[]).map((value) => (
<DropdownMenuItem key={value} onClick={() => handleSortChange(value)}>
{sortLabels[value]}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="sm">
<FilterIcon data-icon="inline-start" aria-hidden />
Фильтры
{activeFilterCount > 0 ? (
<Badge variant="outline" radius="full">
{activeFilterCount}
</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Состояние</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={enabledFilter === 'enabled'}
closeOnClick={false}
onCheckedChange={(checked) => {
setEnabledFilter(checked ? 'enabled' : 'all')
resetPagination()
}}
>
Только включённые
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={enabledFilter === 'disabled'}
closeOnClick={false}
onCheckedChange={(checked) => {
setEnabledFilter(checked ? 'disabled' : 'all')
resetPagination()
}}
>
Только выключенные
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => {
setEnabledFilter('all')
resetPagination()
}}
>
Сбросить фильтры
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{filteredModules.length > 0 ? (
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
) : (
<>
<DataGridScrollArea>
<DataGridTableHeader />
</DataGridScrollArea>
<div className="text-muted-foreground flex min-h-40 items-center justify-center px-4 text-center text-sm">
{EMPTY_MESSAGE}
</div>
</>
)}
<div className="border-t px-4 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
sizes={[10, 15, 20]}
info="{from}{to} из {count}"
className="py-0"
/>
) : (
<p className="text-muted-foreground text-center text-sm">0 модулей</p>
)}
</div>
</div>
</DataGrid>
</DashboardFramePanel>
)
}
@@ -0,0 +1,80 @@
import { useMemo, useState } from 'react'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
import { Badge } from '@/components/reui/badge'
import {
capacityUtilization,
peerCapacityBars,
speakerCapacityBars,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
type CapacityMode = 'peers' | 'speakers'
export function DashboardNetworkHealth({
peers,
speakers,
jobs,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<CapacityMode>('peers')
const bars = useMemo(
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
[mode, peers, speakers],
)
const utilization = capacityUtilization(bars)
const queued = runningJobCount(jobs)
const established =
mode === 'peers'
? peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length
: speakers.filter((s) => s.live?.agent_ok).length
const total =
mode === 'peers' ? peers.filter((p) => p.enabled !== false).length : speakers.length
const offline = Math.max(0, total - established)
return (
<SegmentedProgressCard
title="Загрузка BGP"
description="Утилизация сессий по пирам и спикерам"
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'peers', label: 'Пиры' },
{ value: 'speakers', label: 'Спикеры' },
]}
/>
}
primary={{
value: loading ? '—' : `${utilization}%`,
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
percent: loading ? 0 : utilization,
badge: (
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
{loading ? '…' : `${established}/${total}`}
</Badge>
),
}}
secondary={{
value: loading ? '—' : offline,
label: mode === 'peers' ? 'Не Established' : 'Offline',
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
}}
footer={
loading
? 'Загрузка…'
: `Активных задач: ${queued} · сегментов в графике: ${bars.length}`
}
/>
)
}
@@ -0,0 +1,87 @@
import { useMemo, useState } from 'react'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules'
/** chart-13 inspired donut breakdown (Card surface). */
export function DashboardOperationsBreakdown({
jobs,
modules,
loading,
}: {
jobs: JobRow[]
modules: ModuleRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<FlowMode>('jobs')
const slices = useMemo(
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
[mode, jobs, modules],
)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
return (
<PanelCard
title="Поток операций"
description="Распределение задач и типов модулей"
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'jobs', label: 'Задачи' },
{ value: 'modules', label: 'Модули' },
]}
/>
}
className="h-full"
>
<div className="p-4">
{loading ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
Загрузка
</div>
) : (
<>
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
{slices.length > 0 ? (
<ul className="mt-4 flex min-w-0 flex-col">
{slices.map((slice, index) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-sm font-medium">{slice.label}</span>
</div>
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
{pct}%
</span>
</div>
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
</li>
)
})}
</ul>
) : null}
</>
)}
</div>
</PanelCard>
)
}
@@ -1,40 +0,0 @@
import { Link } from '@tanstack/react-router'
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
export function DashboardQuickActions() {
return (
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
<Tags className="size-4" />
Добавить BGP-сообщество
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button
variant="outline"
size="sm"
type="button"
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
>
<Play className="size-4" />
Деплой
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
<Gauge className="size-4" />
Мониторинг
</Button>
</div>
)
}
@@ -0,0 +1,76 @@
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
const ACTIONS: QuickActionItem[] = [
{
id: 'lookup',
title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
icon: <Plus aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
to: '/directories',
icon: <Tags aria-hidden />,
iconClassName: 'text-info',
},
{
id: 'network',
title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.',
to: '/network',
search: { tab: 'overview' },
icon: <Network aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'add-peer',
title: 'Добавить пира',
description: 'Настройка BGP-соседа и шаблонов сессии.',
to: '/network',
search: { tab: 'peers' },
icon: <Share2 aria-hidden />,
iconClassName: 'text-warning',
},
{
id: 'deploy',
title: 'Деплой',
description: 'Ревизии конфигурации и применение на нодах.',
to: '/operations',
search: { tab: 'revisions' },
icon: <Play aria-hidden />,
iconClassName: 'text-muted-foreground',
},
{
id: 'monitoring',
title: 'Мониторинг',
description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring',
search: { tab: 'system' },
icon: <Gauge aria-hidden />,
iconClassName: 'text-destructive',
},
]
export function DashboardQuickLinks() {
return (
<QuickActionGrid
actions={ACTIONS}
description="Частые переходы к настройке и деплою"
/>
)
}
+22 -13
View File
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
import { cn } from '@evobgp/ui/lib/utils'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card'
import {
panelCardContentFlushClassName,
panelCardFooterClassName,
} from '@/components/panel-card'
import { FrameDataGrid } from '@/components/reui-kit/frame-data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { FrameFooter } from '@/components/reui/frame'
import {
DATA_GRID_MESSAGES_RU,
DATA_GRID_PAGINATION_RU,
@@ -52,33 +57,37 @@ export function DataGridShell<TData extends object>({
<DataGridTable />
</DataGridContainer>
{showPagination ? (
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
<FrameFooter className={cn(panelCardFooterClassName)}>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</div>
</FrameFooter>
) : null}
</DataGrid>
)
}
interface DataGridCardProps {
title?: string
description?: string
/** @deprecated Prefer FrameDataGrid from @/components/reui-kit */
export function DataGridCard({
title,
description,
actions,
children,
className,
}: {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
children: ReactNode
className?: string
}
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
}) {
return (
<PanelCard
<FrameDataGrid
title={title}
description={description}
actions={actions}
className={className}
contentClassName={panelCardContentFlushClassName}
>
{children}
</PanelCard>
<div className={panelCardContentFlushClassName}>{children}</div>
</FrameDataGrid>
)
}
@@ -16,12 +16,13 @@ interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
filters?: ReactNode
actions?: ReactNode
className?: string
}
/** Search row for data grids (ReUI c-input-group-37 pattern). */
/** Search + optional ReUI Filters row for Frame data grids. */
export function DataGridToolbar({
searchValue,
onSearchChange,
@@ -58,19 +59,19 @@ export function DataGridToolbar({
filters
) : (
<InputGroupButton
aria-label="Фильтры"
size="icon-xs"
variant="ghost"
size="xs"
className="pointer-events-none gap-1.5 opacity-0"
aria-hidden="true"
tabIndex={-1}
disabled
title="Передайте filters={<Filters … />} из @/components/reui/filters"
>
<ListFilterIcon className="size-3.5" />
<ListFilterIcon aria-hidden="true" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</Field>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
@@ -0,0 +1,129 @@
import { Button } from "@evobgp/ui/components/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@evobgp/ui/components/card"
import { Input } from "@evobgp/ui/components/input"
import { Label } from "@evobgp/ui/components/label"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<Tabs defaultValue="account">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-sm">
Update your account information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-name" className="text-sm">
Name
</Label>
<Input
id="underline-name"
defaultValue="Alex Chen"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-email" className="text-sm">
Email
</Label>
<Input
id="underline-email"
type="email"
defaultValue="[email protected]"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Password</CardTitle>
<CardDescription className="text-sm">
Change your password here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-current" className="text-sm">
Current password
</Label>
<Input id="underline-current" type="password" className="h-9" />
</div>
<div className="space-y-2">
<Label htmlFor="underline-new" className="text-sm">
New password
</Label>
<Input id="underline-new" type="password" className="h-9" />
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Update password</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Settings</CardTitle>
<CardDescription className="text-sm">
Manage your preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="underline-theme" className="text-sm">
Theme
</Label>
<Input
id="underline-theme"
defaultValue="Light"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label htmlFor="underline-language" className="text-sm">
Language
</Label>
<Input
id="underline-language"
defaultValue="English"
className="h-9"
/>
</div>
</CardContent>
<CardFooter className="pt-3">
<Button size="sm">Save settings</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,195 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null {
if (value == null || value <= 0) return null
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
return String(value)
}
export interface FirewallClientsGridProps {
clients: FirewallClient[]
isLoading?: boolean
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}
export function FirewallClientsGrid({
clients,
isLoading = false,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: FirewallClientsGridProps) {
const columns = useMemo<ColumnDef<FirewallClient>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
subtitle={row.original.hostname || row.original.token_prefix}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
{
id: 'last_seen_at',
accessorFn: (row) => row.last_seen_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
cell: ({ row }) => (
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
),
sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? ''
const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Последняя активность' },
},
{
id: 'apply',
enableSorting: false,
header: 'Применение',
cell: ({ row }) => {
const c = row.original
return (
<span className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</span>
)
},
meta: { headerTitle: 'Применение' },
},
{
id: 'packets',
enableSorting: false,
header: 'Пакеты',
cell: ({ row }) => {
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
if (!dropped && !accepted) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<span className="text-muted-foreground text-xs">
{dropped ? <span className="text-destructive">{dropped}</span> : null}
{dropped && accepted ? ' · ' : null}
{accepted ? <span className="text-success">{accepted}</span> : null}
</span>
)
},
meta: { headerTitle: 'Пакеты' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
type="button"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Удалить
</Button>
}
title="Удалить клиент?"
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
confirmLabel="Удалить"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
)
},
},
],
[approvePending, onApprove, onReject, rejectPending],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: clients,
columns,
getSearchText: (row) =>
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск клиентов…"
/>
)
}
@@ -1,101 +0,0 @@
import { useEffect, useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { useCreateFirewallRule } from '@/queries/firewall'
import type { BgpCommunity } from '@/types/api'
const FIREWALL_ACTION_ITEMS = [
{ value: 'block', label: 'block' },
{ value: 'accept', label: 'accept' },
] as const
interface FirewallRuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
}
export function FirewallRuleCreateDialog({
open,
onOpenChange,
communities,
}: FirewallRuleCreateDialogProps) {
const createMutation = useCreateFirewallRule()
const [action, setAction] = useState<'block' | 'accept'>('block')
const [communityId, setCommunityId] = useState<string | null>(null)
const [comment, setComment] = useState('')
useEffect(() => {
if (!open) return
setAction('block')
setCommunityId(null)
setComment('')
}, [open])
async function save() {
try {
await createMutation.mutateAsync({
scope: 'tenant',
action,
community_id: communityId,
comment: comment.trim(),
})
onOpenChange(false)
} catch {
// toast handled in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новое правило"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
Добавить
</LoadingButton>
</>
}
>
<SelectField
id="fw-rule-action"
label="Действие"
items={[...FIREWALL_ACTION_ITEMS]}
value={action}
placeholder="Выберите действие"
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
/>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="flex flex-col gap-2">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
placeholder="Комментарий"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</FormDrawer>
)
}
@@ -1,122 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api'
export interface FirewallRulesGridProps {
rules: FirewallRule[]
communities: BgpCommunity[]
isLoading?: boolean
onDelete: (id: string) => void
deletePending?: boolean
emptyTitle?: string
}
export function FirewallRulesGrid({
rules,
communities,
isLoading = false,
onDelete,
deletePending = false,
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
}: FirewallRulesGridProps) {
const columns = useMemo<ColumnDef<FirewallRule>[]>(
() => [
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
cell: ({ row }) => row.original.priority,
meta: { headerTitle: '#' },
},
{
accessorKey: 'action',
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
cell: ({ row }) => (
<StatusBadge status={row.original.action} label={row.original.action} />
),
meta: { headerTitle: 'Действие' },
},
{
id: 'community',
enableSorting: false,
header: 'Community',
cell: ({ row }) => (
<span className="text-sm">
{row.original.community_id
? communityLabel(row.original.community_id, communities)
: 'Все'}
</span>
),
meta: { headerTitle: 'Community' },
},
{
accessorKey: 'comment',
enableSorting: false,
header: 'Комментарий',
cell: ({ row }) => row.original.comment || '—',
meta: { headerTitle: 'Комментарий' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const r = row.original
return (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={deletePending}
>
Удалить
</Button>
}
title="Удалить правило?"
description={
r.comment
? `Правило #${r.priority} (${r.action}): ${r.comment}`
: `Правило #${r.priority} (${r.action}) будет удалено.`
}
confirmLabel="Удалить"
destructive
onConfirm={() => onDelete(r.id)}
/>
)
},
},
],
[communities, deletePending, onDelete],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: rules,
columns,
getSearchText: (row) =>
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск правил…"
/>
)
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
* @see https://reui.io/preview/base/stats-12
*/
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatVariant,
type OpsKpiCard,
} from '@/components/reui-kit/kpi-stat-grid'
+140 -83
View File
@@ -9,7 +9,7 @@ import {
BookText,
KeyRound,
ServerCog,
Shield,
Search,
} from 'lucide-react'
import {
@@ -36,16 +36,26 @@ import {
BreadcrumbSeparator,
} from '@evobgp/ui/components/breadcrumb'
import { Separator } from '@evobgp/ui/components/separator'
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, ReactNode } from 'react'
import type { ComponentType, CSSProperties, ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { AppSwitcher } from '@/components/app-switcher'
import { AppsMenu } from '@/components/layout/apps-menu'
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
interface NavItem {
to: string
label: string
icon: ComponentType<{ className?: string }>
description?: string
search?: Record<string, string>
/** Explicit permission override; when omitted derived from `to`. */
permission?: string
}
interface NavGroup {
@@ -56,119 +66,166 @@ interface NavGroup {
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
items: [
{
to: '/dashboard',
label: 'Панель',
icon: LayoutDashboard,
description: 'KPI, модули и активность',
},
],
},
{
label: 'Маршрутизация',
items: [
{ to: '/modules', label: 'Модули', icon: Boxes },
{ to: '/network', label: 'Сеть', icon: Network },
{ to: '/directories', label: 'Справочники', icon: BookText },
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog },
{ to: '/firewall', label: 'Файрвол', icon: Shield },
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
],
},
{
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
{ to: '/settings', label: 'Настройки UI', icon: Settings },
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
/** Nav visible for the current claim (or all items when portal auth is off). */
function useVisibleNavGroups(): NavGroup[] {
if (!isAuthEnabled()) return NAV_GROUPS
return NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
const perm = item.permission ?? permissionForPath(item.to)
return !perm || can(perm)
}),
})).filter((group) => group.items.length > 0)
}
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {}
const PARENT_ROUTE: Record<string, string> = {
'/modules/new': '/modules',
}
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
id: item.to,
label: item.label,
description: item.description,
to: item.to,
search: item.search,
keywords: [item.to.replace(/^\//, '')],
}))
/**
* Shared ops chrome etalon for CFDM / vps-tracker.
* @see https://reui.io/preview/base/app-shell-12
* @see docs/ui-design-contract.md — Shared App Shell chrome
*/
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const visibleGroups = useVisibleNavGroups()
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to]
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
const pageTitle =
pathname.startsWith('/modules/') && pathname !== '/modules/new'
? 'Модуль'
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
B
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
{visibleGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} search={item.search} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,104 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,147 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Search } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Kbd } from '@evobgp/ui/components/kbd'
import {
SidebarGroup,
SidebarGroupContent,
} from '@evobgp/ui/components/sidebar'
export type CommandPaletteItem = {
id: string
label: string
description?: string
to: string
search?: Record<string, string>
keywords?: string[]
}
interface CommandPaletteProps {
items: CommandPaletteItem[]
className?: string
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
hotkeyOnly?: boolean
}
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter((item) => {
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(q)
})
}, [items, query])
function go(item: CommandPaletteItem) {
setOpen(false)
setQuery('')
void navigate({ to: item.to, search: item.search })
}
return (
<>
{!hotkeyOnly ? (
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Поиск
</Button>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
/>
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
) : null}
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery('')
}}
>
<DialogHeader className="sr-only">
<DialogTitle>Быстрый переход</DialogTitle>
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
<div className="flex items-center gap-3 border-b px-4 py-3">
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
<Input
id={searchInputId}
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Раздел или действие…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
}}
/>
</div>
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
{filtered.length === 0 ? (
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
) : (
filtered.map((item) => (
<li key={item.id}>
<button
type="button"
role="option"
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
onClick={() => go(item)}
>
<span className="font-medium">{item.label}</span>
{item.description ? (
<span className="text-muted-foreground text-xs">{item.description}</span>
) : null}
</button>
</li>
))
)}
</ul>
</DialogContent>
</Dialog>
</>
)
}
+229
View File
@@ -0,0 +1,229 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
ExternalLinkIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
authPortalUrl,
clearPortalToken,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(source: string): string {
const base = source.trim()
if (!base) return '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const authOn = isAuthEnabled()
const claims = getClaims()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email =
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
const fallback = initials(name || email)
function handleSignOut() {
if (authOn) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
return
}
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=connection&reason=token-required')
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
>
<SettingsIcon aria-hidden />
Настройки UI
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
>
<ExternalLinkIcon aria-hidden />
Открыть Auth Portal
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
{authOn ? 'Выйти' : 'Сбросить токен'}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,213 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover'
import { Progress } from '@evobgp/ui/components/progress'
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<Progress
value={metric.percent}
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ '--bar-color': color } as CSSProperties}
/>
</div>
)
}
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
export function SystemMonitorPopover() {
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const peers = peersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const network = aggregateNetworkMetrics(peers, [])
const bgpPct =
network.peersEnabled > 0
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
: 0
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const healthOk = healthQ.data?.ok ?? false
const birdRatio =
birdQ.data && birdQ.data.bgp_sessions_total > 0
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
: null
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'api',
label: 'API health',
value: healthOk ? 'OK' : '—',
unit: '',
percent: healthOk ? 100 : 0,
icon: <HeartPulse aria-hidden />,
tone: healthOk ? 'success' : 'destructive',
alert: !healthOk,
},
{
id: 'bgp',
label: 'BGP пиры',
value: String(bgpPct),
unit: '%',
percent: bgpPct,
icon: <Network aria-hidden />,
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive',
alert: bgpPct < 70 && network.peersEnabled > 0,
},
{
id: 'bird',
label: 'BIRD сессии',
value: birdRatio !== null ? String(birdRatio) : '—',
unit: birdRatio !== null ? '%' : '',
percent: birdRatio ?? 0,
icon: <Bird aria-hidden />,
tone:
birdRatio === null
? 'info'
: birdRatio >= 100
? 'success'
: birdRatio >= 50
? 'warning'
: 'destructive',
alert: birdRatio !== null && birdRatio < 100,
},
{
id: 'jobs',
label: 'Ошибки задач',
value: String(failedJobs),
unit: 'шт.',
percent: Math.min(100, failedJobs * 10),
icon: <ListChecks aria-hidden />,
tone: failedJobs > 0 ? 'warning' : 'success',
alert: failedJobs > 0,
},
],
[bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled],
)
const spiking = metrics.some((m) => m.alert) || runningJobs > 5
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
Активных задач: <span className="text-foreground font-medium tabular-nums">{runningJobs}</span>
{' · '}
Пиров в каталоге:{' '}
<span className="text-foreground font-medium tabular-nums">{network.peersTotal}</span>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,199 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import { CommunitySelect } from '@/components/modules/community-select'
import { LoadingButton } from '@/components/loading-button'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { SelectMenu } from '@/components/select-field'
import { ApiError, apiMutate } from '@/lib/api-client'
import type {
BgpCommunity,
LookupQueryKind,
LookupResponse,
ModuleRow,
} from '@/types/api'
/**
* Lookup wizard step 3 — choose module + community and create entry.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/frame
*/
function hostPrefixFromIp(ip: string): string {
return ip.includes(':') ? `${ip}/128` : `${ip}/32`
}
function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
}
export function LookupAddStep({
data,
modules,
communities,
onCancel,
onAdded,
}: {
data: LookupResponse
modules: ModuleRow[]
communities: BgpCommunity[]
onCancel: () => void
onAdded: () => void | Promise<void>
}) {
const wantedType = moduleTypeForKind(data.query_kind)
const eligible = useMemo(
() => modules.filter((m) => m.type === wantedType),
[modules, wantedType],
)
const [moduleId, setModuleId] = useState('')
const [communityId, setCommunityId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (eligible.length === 0) {
setModuleId('')
return
}
setModuleId((prev) =>
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
)
}, [eligible])
useEffect(() => {
const mod = eligible.find((m) => m.id === moduleId)
if (!mod) {
setCommunityId(null)
return
}
setCommunityId(mod.default_community_id ?? null)
}, [moduleId, eligible])
const moduleItems = useMemo(
() =>
eligible.map((m) => ({
value: m.id,
label: m.name,
})),
[eligible],
)
async function handleAdd() {
if (!moduleId) {
toast.error('Выберите модуль')
return
}
if (data.query_kind !== 'domain' && !communityId) {
toast.error('Укажите community')
return
}
setSaving(true)
try {
if (data.query_kind === 'domain') {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', {
fqdn: data.normalized,
community_id: communityId,
})
toast.success('Домен добавлен')
} else {
const prefix =
data.query_kind === 'cidr'
? data.normalized
: hostPrefixFromIp(data.normalized)
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', {
prefix,
community_id: communityId,
})
toast.success('Префикс добавлен')
}
await onAdded()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
if (eligible.length === 0) {
return (
<Alert variant="warning">
<AlertTitle>Нет подходящего модуля</AlertTitle>
<AlertDescription>
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
Перейти к модулям
</Button>
</AlertDescription>
</Alert>
)
}
const valueLabel =
data.query_kind === 'domain'
? data.normalized
: data.query_kind === 'cidr'
? data.normalized
: hostPrefixFromIp(data.normalized)
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Добавить в списки</FrameTitle>
<FrameDescription>
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
<SelectMenu
id="lookup-add-module"
items={moduleItems}
value={moduleId}
placeholder="Выберите модуль"
onValueChange={(v) => {
if (v) setModuleId(v)
}}
/>
</Field>
<CommunitySelect
id="lookup-add-comm"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable={data.query_kind === 'domain'}
/>
</FramePanel>
<FrameFooter className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
Назад
</Button>
<LoadingButton
loading={saving}
onClick={() => void handleAdd()}
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
>
Добавить
</LoadingButton>
</FrameFooter>
</Frame>
)
}
@@ -0,0 +1,134 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { FrameDataGrid } from '@/components/reui-kit'
import { DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { LookupMatch } from '@/types/api'
/**
* Lookup matches grid — data-grid-filtering-2 pattern.
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/badge
*/
export function LookupMatchesGrid({
items,
isLoading = false,
}: {
items: LookupMatch[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<LookupMatch>[]>(
() => [
{
accessorKey: 'layer',
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
cell: ({ row }) => (
<Badge
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
size="sm"
>
{row.original.layer}
</Badge>
),
meta: { headerTitle: 'Слой' },
},
{
accessorKey: 'module_name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.module_name}
subtitle={row.original.module_type}
accent="primary"
/>
),
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'matched_value',
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.matched_value}
subtitle={
row.original.resolved_ip
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
: row.original.match_kind
}
accent="mono"
/>
),
meta: { headerTitle: 'Совпадение' },
},
{
id: 'community',
accessorFn: (row) => row.community_title || row.community || '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
cell: ({ row }) => {
const title = row.original.community_title?.trim()
const value = row.original.community?.trim()
if (!title && !value) {
return <span className="text-muted-foreground text-sm"></span>
}
return (
<DataGridPrimaryCell
title={title || value || '—'}
subtitle={title && value && title !== value ? value : undefined}
/>
)
},
meta: { headerTitle: 'Community' },
},
{
id: 'source',
enableSorting: false,
header: 'Источник',
cell: ({ row }) =>
row.original.source ? (
<CategoryBadge>{row.original.source}</CategoryBadge>
) : (
<span className="text-muted-foreground text-sm"></span>
),
meta: { headerTitle: 'Источник' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
getRowId: (row) =>
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
})
return (
<FrameDataGrid
title="Совпадения"
description="Entries и snapshots · клик по строке открывает модуль"
>
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет совпадений"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Фильтр совпадений…"
onRowClick={(row) =>
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
}
/>
</FrameDataGrid>
)
}
@@ -0,0 +1,76 @@
import { useState, type FormEvent } from 'react'
import { Search } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
/**
* Lookup search form — Frame + InputGroup (form-7 pattern).
* @see https://reui.io/preview/base/form-7
* @see https://reui.io/docs/components/base/frame
*/
export function LookupSearchForm({
initialQuery = '',
isPending = false,
onSubmit,
}: {
initialQuery?: string
isPending?: boolean
onSubmit: (q: string) => void
}) {
const [value, setValue] = useState(initialQuery)
function handleSubmit(e: FormEvent) {
e.preventDefault()
const q = value.trim()
if (!q) return
onSubmit(q)
}
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Проверка списка</FrameTitle>
<FrameDescription>
IP, CIDR или FQDN поиск в entries и материализованных snapshots с community.
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor="lookup-q">IP, CIDR или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
id="lookup-q"
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8, 203.0.113.0/24 или example.com"
autoComplete="off"
autoFocus
/>
</InputGroup>
</Field>
<Button type="submit" disabled={isPending || !value.trim()}>
Проверить
</Button>
</form>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,62 @@
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import type { LookupResponse } from '@/types/api'
/**
* Lookup summary KPI — stats-12 via KpiStatGrid.
* @see https://reui.io/preview/base/stats-12
*/
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
const resolvedCount = data.resolved_ips?.length ?? 0
const items: KpiStatItem[] = [
{
id: 'matched',
label: 'Результат',
value: data.matched ? 'Найдено' : 'Не найдено',
hint: data.normalized,
icon: <Radar aria-hidden />,
iconClassName: data.matched
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
variant: data.matched ? 'default' : 'warning',
},
{
id: 'entry',
label: 'Слой entry',
value: entryCount,
hint: 'сырые списки',
icon: <ListChecks aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
},
{
id: 'snapshot',
label: 'Слой snapshot',
value: snapshotCount,
hint: 'материализация',
icon: <Layers aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
},
]
if (data.query_kind === 'domain') {
items.push({
id: 'resolved',
label: 'DNS IP',
value: resolvedCount,
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
icon: <Globe aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
})
}
return (
<KpiStatGrid
items={items}
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
/>
)
}
@@ -0,0 +1,380 @@
import { useEffect, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Plus, Search, ShieldAlert } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { LookupAddStep } from '@/components/lookup/lookup-add-step'
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
import { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/reui/stepper'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import { lookupKeys, lookupQueryOptions } from '@/queries/lookup'
import { modulesListQueryOptions } from '@/queries/modules'
/**
* Lookup membership wizard — check → result → optional add.
* DNA: wizard-2 · surface frame · stepper.
* @see https://reui.io/preview/base/wizard-2
* @see https://reui.io/docs/components/base/stepper
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const STEP_QUERY = 1
const STEP_RESULT = 2
const STEP_ADD = 3
const STEP_DONE = 4
export function LookupWizard({
q,
onSubmitQuery,
}: {
q: string
onSubmitQuery: (next: string) => void
}) {
const queryClient = useQueryClient()
const trimmed = q.trim()
const lookupQ = useQuery(lookupQueryOptions(trimmed))
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const [step, setStep] = useState(trimmed ? STEP_RESULT : STEP_QUERY)
const [offerAdd, setOfferAdd] = useState(false)
const loadCatalog = step === STEP_ADD || step === STEP_DONE
const modulesQ = useQuery({
...modulesListQueryOptions(),
enabled: loadCatalog,
})
const communitiesQ = useQuery({
...directoriesCommunitiesQueryOptions(),
enabled: loadCatalog,
})
useEffect(() => {
setOfferAdd(false)
setStep(trimmed ? STEP_RESULT : STEP_QUERY)
}, [trimmed])
function resetToQuery() {
setOfferAdd(false)
setStep(STEP_QUERY)
onSubmitQuery('')
}
function goToAdd() {
setOfferAdd(true)
setStep(STEP_ADD)
}
async function handleAdded() {
setStep(STEP_DONE)
await queryClient.invalidateQueries({ queryKey: lookupKeys.query(trimmed) })
await lookupQ.refetch()
}
const data = lookupQ.data
const notFound = data != null && !data.matched
return (
<div className="flex flex-col gap-4 md:gap-6">
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Мастер проверки</FrameTitle>
<FrameDescription>
Проверка IP / CIDR / домена в списках, затем при необходимости добавление.
</FrameDescription>
</FrameHeader>
<FramePanel>
<Stepper
value={step}
onValueChange={setStep}
className="flex flex-col gap-6"
indicators={{
completed: <Check className="size-4" />,
}}
>
<StepperNav>
<StepperItem step={STEP_QUERY} completed={step > STEP_QUERY}>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_QUERY}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Запрос</StepperTitle>
<StepperDescription className="hidden sm:block">
IP, CIDR или FQDN
</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator className="max-sm:hidden" />
</StepperItem>
<StepperItem
step={STEP_RESULT}
completed={step > STEP_RESULT}
disabled={!trimmed}
>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_RESULT}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Результат</StepperTitle>
<StepperDescription className="hidden sm:block">
Есть в списках?
</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator className="max-sm:hidden" />
</StepperItem>
<StepperItem
step={STEP_ADD}
completed={step > STEP_ADD}
disabled={!offerAdd && step !== STEP_ADD && step !== STEP_DONE}
>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_ADD}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Добавление</StepperTitle>
<StepperDescription className="hidden sm:block">
Модуль и community
</StepperDescription>
</div>
</StepperTrigger>
<StepperSeparator className="max-sm:hidden" />
</StepperItem>
<StepperItem step={STEP_DONE} completed={step === STEP_DONE}>
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<StepperIndicator>{STEP_DONE}</StepperIndicator>
<div className="flex flex-col items-start">
<StepperTitle>Готово</StepperTitle>
<StepperDescription className="hidden sm:block">
Подтверждение
</StepperDescription>
</div>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={STEP_QUERY}>
<LookupSearchForm
key={`form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
{!trimmed ? (
<div className="mt-4">
<EmptyState
icon={<Search className="size-8" />}
title="Введите IP, CIDR или домен"
description="Например 8.8.8.8, 203.0.113.0/24 или example.com — проверка по entries и snapshots."
/>
</div>
) : null}
</StepperContent>
<StepperContent value={STEP_RESULT}>
{!trimmed ? (
<EmptyState
icon={<Search className="size-8" />}
title="Сначала выполните проверку"
description="Вернитесь к шагу «Запрос» и укажите значение."
/>
) : (
<QueryState
data={lookupQ.data}
isLoading={lookupQ.isLoading}
isError={lookupQ.isError}
error={lookupQ.error}
onRetry={() => void lookupQ.refetch()}
skeleton={
<div className="flex flex-col gap-4 md:gap-6">
<SectionCardsSkeleton />
<TableSkeleton rows={5} />
</div>
}
>
{(result) => (
<div className="flex flex-col gap-4 md:gap-6">
<LookupSearchForm
key={`result-form-${trimmed}`}
initialQuery={trimmed}
isPending={lookupQ.isFetching}
onSubmit={onSubmitQuery}
/>
<LookupSummaryKpi data={result} />
{result.matched ? (
<>
<Alert variant="success">
<Check className="size-4" />
<AlertTitle>Уже есть в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» найден в entries и/или snapshots
({result.match_count} совпад.).
</AlertDescription>
<AlertAction>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
<LookupMatchesGrid
items={result.matches}
isLoading={lookupQ.isFetching}
/>
</>
) : canWrite ? (
<Alert variant="info">
<Plus className="size-4" />
<AlertTitle>Не найдено в списках</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует. Добавить запись?
</AlertDescription>
<AlertAction>
<Button
type="button"
size="xs"
onClick={() => void goToAdd()}
>
Добавить
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
) : (
<Alert variant="warning">
<ShieldAlert className="size-4" />
<AlertTitle>Не найдено</AlertTitle>
<AlertDescription>
«{result.normalized}» отсутствует в списках. У вас нет
права добавлять записи (нужно bgp:modules:write).
</AlertDescription>
<AlertAction>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
)}
</div>
)}
</QueryState>
)}
</StepperContent>
<StepperContent value={STEP_ADD}>
{data && notFound ? (
<LookupAddStep
data={data}
modules={modulesQ.data?.items ?? []}
communities={communitiesQ.data?.items ?? []}
onCancel={() => {
setOfferAdd(false)
setStep(STEP_RESULT)
}}
onAdded={handleAdded}
/>
) : (
<Alert variant="warning">
<AlertTitle>Добавление недоступно</AlertTitle>
<AlertDescription>
Сначала выполните проверку для значения, которого ещё нет в списках.
</AlertDescription>
</Alert>
)}
</StepperContent>
<StepperContent value={STEP_DONE}>
<div className="flex flex-col gap-4 md:gap-6">
<Alert variant="success">
<Check className="size-4" />
<AlertTitle>Запись добавлена</AlertTitle>
<AlertDescription>
Повторная проверка обновлена. Можно посмотреть совпадения или
начать новый запрос.
</AlertDescription>
<AlertAction>
<Button
type="button"
size="xs"
onClick={() => setStep(STEP_RESULT)}
>
К результату
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={resetToQuery}
>
Новая проверка
</Button>
</AlertAction>
</Alert>
{lookupQ.data ? (
<>
<LookupSummaryKpi data={lookupQ.data} />
{lookupQ.data.matched ? (
<LookupMatchesGrid
items={lookupQ.data.matches}
isLoading={lookupQ.isFetching}
/>
) : null}
</>
) : null}
</div>
</StepperContent>
</StepperPanel>
</Stepper>
</FramePanel>
</Frame>
</div>
)
}
-29
View File
@@ -1,29 +0,0 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -5,7 +5,7 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridCard } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
@@ -128,7 +128,7 @@ export function ModuleEntriesSection({
return (
<>
<DataGridCard
<FrameDataGrid
title={meta.title}
description={meta.description}
actions={
@@ -166,7 +166,7 @@ export function ModuleEntriesSection({
/>
)}
</QueryState>
</DataGridCard>
</FrameDataGrid>
{mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog
@@ -1,13 +1,7 @@
import {
ArrowDownUp,
Network,
RefreshCw,
ShieldCheck,
Timer,
} from 'lucide-react'
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
@@ -15,6 +9,7 @@ import {
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import { KpiStatGridSkeleton } from '@/components/skeletons'
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
interface ModuleKpiCardsProps {
@@ -33,52 +28,65 @@ export function ModuleKpiCards({
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
return <SectionCardsSkeleton count={5} />
return <KpiStatGridSkeleton count={4} />
}
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const items: SectionCardItem[] = [
const items: KpiStatItem[] = [
{
label: 'Приоритет',
id: 'priority',
icon: <Gauge aria-hidden />,
iconClassName: 'text-info',
value: String(mod.priority ?? 0),
hint: 'порядок в сборке ревизии',
icon: <ArrowDownUp className="size-3.5" />,
label: 'Приоритет',
footer: (
<Badge variant={mod.enabled !== false ? 'success-light' : 'outline'} size="sm">
{mod.enabled !== false ? 'активен' : 'выключен'} · {mod.type}
</Badge>
),
},
{
label: 'Интервал',
id: 'interval',
icon: <Clock aria-hidden />,
iconClassName: 'text-warning',
value: moduleIntervalLabel(mod),
hint: 'refresh_interval_sec / cron',
icon: <Timer className="size-3.5" />,
label: 'Интервал обновления',
footer: (
<Badge variant="primary-light" size="sm">
{formatDateTime(mod.last_refreshed_at) || 'никогда'}
</Badge>
),
},
{
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
hint:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: <Network className="size-3.5" />,
id: 'prefixes',
icon: <Tags aria-hidden />,
iconClassName: 'text-success',
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
footer: (
<Badge variant="success-light" size="sm">
{asEntries.length} AS · в модуле
</Badge>
),
},
{
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
hint: 'для записей без своего community',
icon: <ShieldCheck className="size-3.5" />,
},
{
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
hint:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: <RefreshCw className="size-3.5" />,
id: 'policy',
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: dohIds.length > 0 ? String(dohIds.length) : '—',
label: communityLabel(mod.default_community_id, communities),
footer: (
<Badge variant="info-light" size="sm">
{dohPolicyRu(mod.doh_resolver_policy)}
{dohIds.length > 0
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
: ' · без DoH'}
</Badge>
),
},
]
return <SectionCards items={items} />
return <KpiStatGrid items={items} aria-label="KPI модуля" />
}
@@ -0,0 +1,274 @@
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CheckIcon, SearchIcon, XIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import {
useApproveDiscoveredPeerMutation,
useRejectDiscoveredPeerMutation,
} from '@/queries/network'
import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
/**
* Pending BGP peer discoveries approve / reject.
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/components/c-empty-1
*/
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
function createDefaultFilters(): Filter[] {
return [createFilter('neighbor', 'contains', [''])]
}
const filterFields: FilterFieldConfig[] = [
{
key: 'neighbor',
label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'IP или Neighbor ID…',
},
]
interface NetworkDiscoveredPeersCardProps {
items: PeerDiscoveryRow[]
speakers: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkDiscoveredPeersCard({
items,
speakers,
isLoading,
isError,
error,
onRetry,
}: NetworkDiscoveredPeersCardProps) {
const approveMutation = useApproveDiscoveredPeerMutation()
const rejectMutation = useRejectDiscoveredPeerMutation()
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
const [name, setName] = useState('')
const [speakerId, setSpeakerId] = useState<string | null>(null)
const speakerItems = useMemo(
() => [
{ value: '', label: 'Авто (с ноды обнаружения)' },
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
],
[speakers],
)
const columns = useMemo<ColumnDef<PeerDiscoveryRow, unknown>[]>(
() => [
{
id: 'neighbor_id',
accessorFn: (row) => row.neighbor_id || row.neighbor,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Neighbor ID" />
),
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.neighbor_id || '—'}
subtitle={row.original.neighbor}
accent="mono"
/>
),
meta: { headerTitle: 'Neighbor ID' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn || '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Состояние" />
),
cell: ({ row }) => (
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
),
meta: { headerTitle: 'Состояние' },
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
setApproveTarget(row.original)
setName('')
setSpeakerId(row.original.speaker_id ?? null)
}}
>
<CheckIcon />
Одобрить
</Button>
<Button
size="sm"
type="button"
variant="ghost"
onClick={() => setRejectTarget(row.original)}
>
<XIcon />
Отклонить
</Button>
</div>
),
},
],
[],
)
function getFilterFieldValue(item: PeerDiscoveryRow, field: string): unknown {
if (field === 'neighbor') {
return `${item.neighbor_id ?? ''} ${item.neighbor}`
}
return undefined
}
async function confirmApprove() {
if (!approveTarget) return
await approveMutation.mutateAsync({
id: approveTarget.id,
body: {
name: name.trim() || undefined,
bgp_speaker_id: speakerId || null,
},
})
setApproveTarget(null)
}
return (
<>
<ResourcePage
title="На одобрение"
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultFilters())}
getFilterFieldValue={getFilterFieldValue}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
emptyState={{
title: 'Нет ожидающих пиров',
description:
'Включите peer discovery в параметрах BIRD и задайте CIDR-диапазоны. Новые сессии появятся здесь.',
}}
/>
<FormDrawer
open={!!approveTarget}
onOpenChange={(open) => {
if (!open) setApproveTarget(null)
}}
title="Одобрить пира"
description={
approveTarget
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
: undefined
}
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => setApproveTarget(null)}>
Отмена
</Button>
<LoadingButton
type="button"
loading={approveMutation.isPending}
onClick={() => void confirmApprove()}
>
Одобрить
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="disc-name">Имя (опционально)</Label>
<Input
id="disc-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="client-edge-1"
/>
</div>
<SelectField
id="disc-speaker"
label="Спикер"
items={speakerItems}
value={speakerId ?? ''}
onValueChange={(v) => setSpeakerId(v || null)}
placeholder="Авто"
/>
</FormDrawer>
<ConfirmDialog
open={!!rejectTarget}
onOpenChange={(open) => {
if (!open) setRejectTarget(null)
}}
title="Отклонить пира?"
description={
rejectTarget
? `${rejectTarget.neighbor_id || rejectTarget.neighbor} больше не будет появляться в списке.`
: undefined
}
confirmLabel="Отклонить"
destructive
confirmLoading={rejectMutation.isPending}
onConfirm={() => {
if (!rejectTarget) return
void rejectMutation.mutateAsync(rejectTarget.id).then(() => setRejectTarget(null))
}}
/>
</>
)
}
@@ -0,0 +1,115 @@
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
/**
* Network page KPI strip.
* @see https://reui.io/preview/base/stats-12
*/
export function NetworkKpi({
peers,
speakers,
bird,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
bird?: BirdStatus
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const birdHealthy = bird?.healthy
const birdSessions =
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
const cards: KpiStatCardData[] = [
{
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${net.peersTotal} в каталоге`}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
variant={
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading ? '…' : 'live'}
</Badge>
),
to: '/network',
search: { tab: 'speakers' },
},
{
id: 'mismatches',
icon: <AlertTriangle aria-hidden />,
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
label: 'Расхождения',
value: loading ? '—' : String(net.peersMismatch),
variant: net.peersMismatch > 0 ? 'warning' : 'default',
footer: (
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'bird',
icon: <Network aria-hidden />,
iconClassName:
birdHealthy === false ? 'text-destructive' : 'text-primary',
label: 'BIRD',
value: loading ? '—' : birdSessions,
variant: birdHealthy === false ? 'destructive' : 'default',
footer: (
<Badge
variant={
birdHealthy === true
? 'success-light'
: birdHealthy === false
? 'destructive-light'
: 'outline'
}
size="sm"
>
{loading
? '…'
: birdHealthy === true
? 'в норме'
: birdHealthy === false
? 'проблема'
: 'н/д'}
</Badge>
),
},
]
return (
<section aria-label="Ключевые метрики сети">
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
</section>
)
}
@@ -1,15 +1,77 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getPeerFilterFieldValue,
peerColumns,
peerTabFilter,
} from '@/components/network/network-peers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { PeerRow, SpeakerRow } from '@/types/api'
/**
* BGP peers list ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
{ value: 'OpenSent', label: 'OpenSent' },
{ value: 'OpenConfirm', label: 'OpenConfirm' },
]
function createDefaultPeerFilters(): Filter[] {
return [createFilter('name', 'contains', [''])]
}
const peerFilterFields: FilterFieldConfig[] = [
{
key: 'name',
label: 'Имя',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Поиск по имени…',
},
{
key: 'neighbor',
label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Адрес соседа…',
},
{
key: 'session_state',
label: 'Состояние',
icon: <ActivityIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[160px]',
options: SESSION_STATE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
},
]
interface NetworkPeersCardProps {
items: PeerRow[]
speakers: SpeakerRow[]
@@ -28,41 +90,46 @@ export function NetworkPeersCard({
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
),
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<PeerFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
speakers={speakers}
tabs={PEER_TABS}
tabFilter={peerTabFilter}
filterFields={peerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultPeerFilters())}
getFilterFieldValue={getPeerFilterFieldValue}
columns={peerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет пиров',
description: 'Добавьте первого BGP-соседа.',
action: addButton,
}}
/>
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
</>
)
}
@@ -1,90 +1,77 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({
items,
isLoading = false,
}: {
items: PeerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<PeerRow>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
],
[],
)
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? ''}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id,
})
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) {
case 'name':
return item.name ?? item.neighbor
case 'neighbor':
return item.neighbor
case 'session_state':
return item.session_state
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
if (tabId === 'disabled') return item.enabled === false
const enabled = item.enabled !== false
if (tabId === 'established') return enabled && item.session_state === 'Established'
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
return true
}
@@ -1,15 +1,72 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Plus, SearchIcon, TagIcon, Trash2 } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getSpeakerFilterFieldValue,
speakerColumns,
speakerTabFilter,
} from '@/components/network/network-speakers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import { useDeleteSpeakerMutation } from '@/queries/network'
import type { SpeakerRow } from '@/types/api'
/**
* BGP speakers list ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
function speakerDeleteLabel(s: SpeakerRow): string {
return s.agent_domain ?? s.endpoint ?? s.id
}
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
{ id: 'offline', label: 'Offline' },
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' },
{ value: 'secondary', label: 'secondary' },
{ value: 'speaker', label: 'speaker' },
]
function createDefaultSpeakerFilters(): Filter[] {
return [createFilter('endpoint', 'contains', [''])]
}
const speakerFilterFields: FilterFieldConfig[] = [
{
key: 'endpoint',
label: 'Конечная точка',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'endpoint…',
},
{
key: 'role',
label: 'Роль',
icon: <TagIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[140px]',
options: ROLE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, ROLE_OPTIONS),
},
]
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
@@ -25,38 +82,97 @@ export function NetworkSpeakersCard({
error,
onRetry,
}: NetworkSpeakersCardProps) {
const deleteMutation = useDeleteSpeakerMutation()
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<SpeakerRow | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
),
[],
)
const columns = useMemo<ColumnDef<SpeakerRow, unknown>[]>(
() => [
...speakerColumns,
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
aria-label="Удалить спикера"
title="Удалить"
onClick={() => setDeleteTarget(row.original)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
],
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
getFilterFieldValue={getSpeakerFilterFieldValue}
columns={columns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет спикеров',
description: 'Добавьте первого BIRD-агента на ноде.',
action: addButton,
}}
/>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить спикера?"
description={
deleteTarget
? `${speakerDeleteLabel(deleteTarget)} будет удалён. Привязки пиров к этому спикеру сбросятся.`
: undefined
}
confirmLabel="Удалить"
confirmLoadingLabel="Удаление…"
destructive
confirmLoading={deleteMutation.isPending}
onConfirm={() => {
if (!deleteTarget) return
void deleteMutation.mutateAsync(deleteTarget.id).then(() => setDeleteTarget(null))
}}
/>
</>
)
}
@@ -1,87 +1,78 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({
items,
isLoading = false,
}: {
items: SpeakerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
() => [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
return <Badge variant="outline" size="sm" radius="full"></Badge>
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
],
[],
)
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) {
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
}
if (live?.agent_ok === false) {
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
}
return (
<Badge variant="outline" size="sm" radius="full">
</Badge>
)
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id,
})
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
return item.endpoint
case 'role':
return item.role
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
if (tabId === 'online') return item.live?.agent_ok === true
if (tabId === 'offline') return item.live?.agent_ok !== true
return true
}
@@ -0,0 +1,86 @@
import { useMemo, useState } from 'react'
import { FrameDataGrid } from '@/components/reui-kit'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
type JobTab = 'all' | 'active' | 'failed' | 'succeeded'
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
if (tab === 'all') return items
if (tab === 'active') return items.filter((j) => j.status === 'running' || j.status === 'queued')
if (tab === 'failed')
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
return items.filter((j) => j.status === 'succeeded')
}
function tabCounts(items: JobRow[]) {
return {
all: items.length,
active: items.filter((j) => j.status === 'running' || j.status === 'queued').length,
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.length,
succeeded: items.filter((j) => j.status === 'succeeded').length,
}
}
/** data-grid-filtering-1 style jobs card with status tabs. */
export function OperationsJobsCard({
jobs,
nameById,
qc,
isLoading,
isError,
error,
onRetry,
}: {
jobs: JobRow[]
nameById: Map<string, string>
qc: QueryClient
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}) {
const [tab, setTab] = useState<JobTab>('all')
const counts = useMemo(() => tabCounts(jobs), [jobs])
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
return (
<FrameDataGrid title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
<TabsTrigger value="failed">Ошибки ({counts.failed})</TabsTrigger>
</TabsList>
</Tabs>
</div>
<QueryState
data={filtered}
isLoading={isLoading}
isError={isError}
error={error}
empty={filtered.length === 0}
emptyTitle="Нет задач в выборке"
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={onRetry}
>
{(items) => (
<OperationsJobsGrid
items={items}
nameById={nameById}
qc={qc}
isLoading={isLoading && items.length > 0}
/>
)}
</QueryState>
</FrameDataGrid>
)
}
+53 -27
View File
@@ -1,21 +1,24 @@
import type { ReactNode } from 'react'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
/** REUI c-data-grid-19: card shell with default spacing and flush grid body. */
export const panelCardClassName = 'gap-0 has-data-[slot=card-footer]:pb-0'
/**
* Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
* @see https://reui.io/docs/components/base/frame
*/
export const panelCardClassName = 'w-full gap-0 p-0'
export const panelCardHeaderClassName = 'border-b'
export const panelCardContentFlushClassName = 'px-0'
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
@@ -45,22 +48,45 @@ export function PanelCard({
size = 'default',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Card size={size} className={cn(panelCardClassName, className)}>
{hasHeader ? (
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <CardDescription>{description}</CardDescription> : null}
{actions ? <CardAction>{actions}</CardAction> : null}
</CardHeader>
) : null}
{children != null && children !== false ? (
<CardContent className={contentClassName}>{children}</CardContent>
) : null}
{footer ? (
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
) : null}
</Card>
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
) : null}
{children != null && children !== false ? (
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
) : null}
{footer ? (
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,92 @@
import { Cell, Pie, PieChart } from 'recharts'
import { PanelCard } from '@/components/panel-card'
import { Badge } from '@/components/reui/badge'
import {
ChartContainer,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import type { BreakdownSlice } from '@/lib/metrics'
/** chart-27 / chart-13 inspired donut in PanelCard. */
export function DonutBreakdownCard({
title,
description,
slices,
centerLabel,
badge,
}: {
title: string
description?: string
slices: BreakdownSlice[]
centerLabel: string
badge?: string
}) {
const total = slices.reduce((sum, s) => sum + s.count, 0)
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
acc[slice.key] = { label: slice.label, color: slice.color }
return acc
}, {})
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
return (
<PanelCard title={title} description={description} className="h-full">
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
{total === 0 ? (
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
) : (
<>
<div className="relative mx-auto size-36 shrink-0">
<ChartContainer config={chartConfig} className="aspect-square size-36">
<PieChart>
<Pie
data={data}
dataKey="share"
nameKey="label"
innerRadius={48}
outerRadius={64}
strokeWidth={2}
stroke="var(--color-card)"
>
{data.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-muted-foreground text-xs">{centerLabel}</span>
<span className="text-lg font-semibold tabular-nums">{total}</span>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-2">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm">
<span className="flex min-w-0 items-center gap-2">
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: slice.color }}
aria-hidden
/>
<span className="truncate">{slice.label}</span>
</span>
<span className="text-muted-foreground shrink-0 tabular-nums">
{slice.count} ({pct}%)
</span>
</li>
)
})}
</ul>
</>
)}
{badge ? (
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
{badge}
</Badge>
) : null}
</div>
</PanelCard>
)
}
@@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import { Card, CardContent } from '@evobgp/ui/components/card'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
/** empty-state-11 pattern adapted to Card surface. */
export function IllustratedEmptyState({
title,
description,
icon: Icon,
action,
}: {
title: string
description: string
icon: LucideIcon
action?: ReactNode
}) {
return (
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{action ? <div className="w-full self-end">{action}</div> : null}
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden>
<Icon strokeWidth={1.9} aria-hidden />
</IconStack>
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
)
}
@@ -0,0 +1,7 @@
export { DonutBreakdownCard } from './donut-breakdown-card'
export { IllustratedEmptyState } from './illustrated-empty-state'
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
export { PanelCorners } from './panel-corners'
export { ProjectsEmptyState } from './projects-empty-state'
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
@@ -0,0 +1,77 @@
import type { ComponentProps } from 'react'
import { Badge } from '@/components/reui/badge'
import { CardDotField } from '@/components/dashboard/card-dot-field'
import { PanelCorners } from '@/components/patterns/panel-corners'
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
import { Card, CardContent } from '@evobgp/ui/components/card'
export type KpiSparklineMetric = {
id: string
title: string
label: string
value: string
delta: string
deltaVariant: ComponentProps<typeof Badge>['variant']
detail: string
tone: MetricTone
sparkline: readonly number[]
}
function Sparkline({ values, color }: { values: readonly number[]; color: string }) {
const min = Math.min(...values)
const max = Math.max(...values)
const spread = Math.max(1, max - min)
const points = values
.map((value, index) => {
const x = (index / (values.length - 1)) * 72
const y = 28 - ((value - min) / spread) * 22
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
return (
<svg viewBox="0 0 72 32" className="h-9 w-24 shrink-0 opacity-95" role="img" aria-label="Тренд">
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
)
}
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
const tone = toneStyles[metric.tone]
return (
<Card className="relative overflow-hidden p-0">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
</div>
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 space-y-2.5">
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
{metric.value}
</div>
<div className="flex items-center gap-2">
<Badge variant={metric.deltaVariant} radius="full" size="sm">
{metric.delta}
</Badge>
<span className="text-muted-foreground text-xs">{metric.detail}</span>
</div>
</div>
<Sparkline values={metric.sparkline} color={tone.stroke} />
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,8 @@
export type MetricTone = 'danger' | 'success' | 'warning' | 'info'
export const toneStyles: Record<MetricTone, { stroke: string }> = {
danger: { stroke: 'var(--color-destructive)' },
success: { stroke: 'var(--color-success)' },
warning: { stroke: 'var(--color-warning)' },
info: { stroke: 'var(--color-info)' },
}
@@ -0,0 +1,15 @@
/** Corner accents from ReUI dashboard-5 block. */
export function PanelCorners() {
return (
<>
<span
aria-hidden
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
/>
<span
aria-hidden
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
/>
</>
)
}
@@ -0,0 +1,22 @@
import { Link } from '@tanstack/react-router'
import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
export function ProjectsEmptyState() {
return (
<IllustratedEmptyState
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
action={
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Новый модуль
</Button>
}
/>
)
}
@@ -0,0 +1,70 @@
import type { ReactNode } from 'react'
import { PanelCard } from '@/components/panel-card'
import { cn } from '@evobgp/ui/lib/utils'
import { Progress } from '@evobgp/ui/components/progress'
export type SegmentStat = {
value: string | number
label: string
percent: number
badge?: ReactNode
}
/** stats-4 pattern: dual metrics + progress + segmented meter (Card surface). */
export function SegmentedProgressCard({
title,
description,
actions,
primary,
secondary,
segments = 30,
footer,
}: {
title: string
description?: string
actions?: ReactNode
primary: SegmentStat
secondary: SegmentStat
segments?: number
footer?: ReactNode
}) {
const filled = Math.round((secondary.percent / 100) * segments)
return (
<PanelCard title={title} description={description} actions={actions} className="h-full">
<div className="flex flex-col gap-4 p-4">
<div className="flex items-stretch gap-x-6">
<div className="flex flex-1 flex-col items-start gap-1">
<div className="mb-1 flex items-center gap-1">
<span className="text-foreground text-2xl font-bold tabular-nums">{primary.value}</span>
{primary.badge}
</div>
<span className="text-muted-foreground text-sm font-medium">{primary.label}</span>
<div className="mt-1 w-full">
<Progress value={primary.percent} className="**:data-[slot=progress-track]:h-2.5" />
</div>
</div>
<div className="border-muted-foreground/10 flex flex-1 flex-col items-start gap-1 border-s ps-6">
<div className="mb-1 flex items-center gap-1">
<span className="text-foreground text-2xl font-bold tabular-nums">{secondary.value}</span>
</div>
<span className="text-muted-foreground text-sm font-medium">{secondary.label}</span>
<div className="mt-1 flex w-full gap-0.5">
{Array.from({ length: segments }).map((_, i) => (
<div
key={i}
className={cn(
'h-2.5 w-0.5 flex-1 rounded-md',
i < filled ? 'bg-success' : 'bg-muted',
)}
/>
))}
</div>
</div>
</div>
{footer ? <div className="text-muted-foreground text-xs">{footer}</div> : null}
</div>
</PanelCard>
)
}
+3
View File
@@ -13,6 +13,7 @@ interface QueryStateProps<T> {
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
emptyContent?: ReactNode
onRetry?: () => void
skeleton?: ReactNode
children: (data: T) => ReactNode
@@ -27,6 +28,7 @@ export function QueryState<T>({
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
emptyContent,
onRetry,
skeleton,
children,
@@ -52,6 +54,7 @@ export function QueryState<T>({
)
}
if (empty || data == null) {
if (emptyContent) return <>{emptyContent}</>
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
}
return <>{children(data)}</>
@@ -0,0 +1,116 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evobgp/ui/lib/utils'
export interface DetailMetricCard {
id: string
icon: ReactNode
label: string
description: string
footer?: ReactNode
}
interface DetailPanelProps {
children: ReactNode
className?: string
}
function DetailPanelRoot({ children, className }: DetailPanelProps) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
interface DetailPanelHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
}
function DetailPanelHeader({
title,
description,
actions,
children,
}: DetailPanelHeaderProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
</Frame>
)
}
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
return (
<div className="@container w-full">
<div className="grid gap-4 @2xl:grid-cols-3">
{cards.map((card) => (
<Frame key={card.id} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
{card.footer}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
function DetailPanelSection({
title,
description,
children,
}: {
title?: string
description?: string
children: ReactNode
}) {
return (
<section className="flex flex-col gap-3">
{title ? (
<header className="px-1">
<h2 className="text-sm font-semibold">{title}</h2>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
) : null}
{children}
</section>
)
}
export const DetailPanel = Object.assign(DetailPanelRoot, {
Header: DetailPanelHeader,
Metrics: DetailPanelMetrics,
Section: DetailPanelSection,
})

Some files were not shown because too many files have changed in this diff Show More