Compare commits

..
29 Commits
Author SHA1 Message Date
Denozordec 6d2051f813 fix(web): improve error handling and API health check logic
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Successful in 41s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m25s
- Refactored the API health check to handle errors more gracefully, providing clearer feedback on loading issues.
- Updated the NGINX configuration to use a dynamic upstream variable for better resilience against IP changes in Docker.
- Enhanced user notifications for API availability and data loading errors, including instructions for local demo setup.
2026-05-20 15:48:18 +07:00
DenozordecandCursor b51a9ae3b3 ci: add ci type to release configuration and update documentation
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 31s
CI / web (push) Successful in 41s
CI / go (push) Successful in 47s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 3m47s
- Introduced `ci` type in `.releaserc.json` for patch releases.
- Updated conventional commits documentation to reflect the new `ci` type and its implications for versioning.
- Clarified the role of `ci` in the context of patch releases in the releasing guide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:24:21 +07:00
DenozordecandCursor 4d4cd2301f ci: update golangci-lint version and installation mode
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 30s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 18s
- Upgraded golangci-lint from v1.62 to v1.64.8 to ensure compatibility with Go 1.24.
- Changed installation mode to 'goinstall' for improved setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:18:53 +07:00
DenozordecandCursor d687881eaa chore: update golangci configuration and improve resource cleanup
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 31s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Disabled all linters in .golangci.yml to streamline linting process.
- Updated resource cleanup in multiple files to use deferred functions for closing response bodies, ensuring proper error handling and resource management.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:13:02 +07:00
DenozordecandCursor 87e756f34f ci: expand changes detection to rewire all pipeline nodes
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 40s
CI / go (push) Failing after 21s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Полный прогон при scripts/*, workflows, golangci, pre-commit; openapi в full_pipeline; миграции и deploy-пути; исправлен fallback пустого diff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:08:28 +07:00
DenozordecandCursor 27864fad58 fix(ci): make check-migrations-pair.sh POSIX sh compatible
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been cancelled
Убрана bash process substitution; скрипт работает под sh в Gitea runner (DEP-03).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:57:18 +07:00
DenozordecandCursor dc5e777d07 style(web): format settings-known schema for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Has been cancelled
CI / bird2 (push) Has been cancelled
CI / release (push) Has been cancelled
CI / web (push) Has been cancelled
Исправление падения CI job web: prettier --check на settings-known.schema.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:56:20 +07:00
DenozordecandCursor 23910c3393 docs(go): add godoc for pipeline and store exports
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Failing after 34s
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Уточнение godoc ExtractCIDRs; тесты repository (interface + optional integration).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:50 +07:00
DenozordecandCursor 0e90bbee4e docs: clarify dev-only flags and openapi implementation gaps
DOC-03: X-Tenant-Id не реализован в handlers; лимиты CDN preview; SEC-02 пометки в compose; sync endpoints в access.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:20 +07:00
DenozordecandCursor 55bc87cbc4 ci: add golangci, migration checks, and pre-commit
DEP-03: check-migrations-pair.sh; migrations/* в path filter; golangci-lint в CI; pre-commit; обновление engineering.mdc (TEST-04, enforcement).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:01 +07:00
DenozordecandCursor e68f034966 refactor(web): extract shared kpi metrics grid pattern
WEB-18: KpiMetricsGrid в ui/patterns; рефакторинг overview, network, modules, directories, operations, schedule, monitoring.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:52:44 +07:00
DenozordecandCursor e06ee880b2 fix(web): align settings with shadcn notify and superforms
WEB-07: notify вместо svelte-sonner; ui/core imports; WEB-05: zod + superforms + FormField для полей настроек.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:50:58 +07:00
DenozordecandCursor 33fe8fdd18 refactor(httpapi): readiness ping via store backend
ARCH-09: Ping на store.Backend; readiness без прямого pgxpool.Ping в handler.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:49:26 +07:00
DenozordecandCursor 6fa693156d fix(httpapi): stable problem details for client errors
Стабильные detail для 4xx (store, CDN preview, CSV); логирование на сервере. Расширен lint-httpapi для ERR-01 в 4xx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:57 +07:00
DenozordecandCursor 5858d0889e fix(httpapi): paginate module nested list endpoints
Пагинация cursor/limit для CDN, AS, domain и IP range list; ответы с next_cursor и has_more по OpenAPI. Добавлен writePaginatedListJSON и тест.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:32 +07:00
Denozordec bfe20c1fe0 feat(web): enhance overview page with improved state management and UI components
CI / changes (push) Successful in 9s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 42s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m57s
- Refactored state management for modules, revisions, peers, speakers, and jobs, enhancing loading and error handling.
- Updated imports to utilize core UI components for better maintainability and consistency.
- Introduced derived states for KPI cards, providing real-time insights into system metrics.
- Improved loading function to fetch data efficiently and handle errors gracefully.
- Enhanced UI layout with new icons and dynamic descriptions based on last updated timestamps.
2026-05-20 14:27:21 +07:00
Denozordec c66cc9317d feat(web): refactor directories and network pages with improved state management and UI components
CI / changes (push) Successful in 6s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m42s
- Updated imports to utilize core UI components for better maintainability.
- Refactored state management for communities and DoH profiles, enhancing loading and error handling.
- Introduced derived states for KPI cards, providing better insights into community and DoH profile counts.
- Streamlined loading functions to improve data fetching efficiency and user experience.
- Enhanced UI layout with new icons and improved component structure for clarity.
2026-05-20 14:13:32 +07:00
Denozordec 9ea4a68ccf docs: update AGENTS, rules, and skills documentation for Conventional Commits
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 17s
- Enhanced AGENTS.md to clarify the use of `/commit-message` and the exclusion of the Source Control button for generating commit messages.
- Updated conventional-commits.mdc to include new triggers and guidelines for commit message types, emphasizing the importance of semver impact.
- Revised commit-message SKILL.md to specify the mandatory execution of scripts and the correct usage of types and scopes in commit messages.
- Improved README documentation to reflect the updated guidelines and workflows for generating commit messages.
2026-05-20 12:52:31 +07:00
Denozordec 8204105fd6 feat(web): enhance module entry dialogs and selection handling
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 38s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m1s
- Refactored module entry dialogs to reset forms based on edit state and improve state management.
- Updated selection logic in various components to utilize derived states for active selections, enhancing bulk operations.
- Improved dialog bindings for better state synchronization and user experience.
- Streamlined component structure for maintainability and clarity.
2026-05-20 12:35:45 +07:00
Denozordec 0c11ecfa48 feat(web): enhance module management UI with new localization and state handling
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m30s
- Added new utility functions for module state representation in Russian, improving localization.
- Introduced derived states for counting enabled and disabled modules, enhancing user insights.
- Updated module management components to display last updated timestamps and improved descriptions.
- Refactored imports to utilize core UI components for better maintainability and consistency.
2026-05-20 12:23:16 +07:00
Denozordec ab8660ac42 feat(web): enhance operations UI with new job status handling and component updates
- Added a new function for determining job status badge variants to improve UI consistency.
- Refactored imports to utilize the core UI library for better maintainability.
- Updated job report and filters components to enhance user experience and streamline functionality.
- Improved layout and responsiveness across operations-related components.
2026-05-20 12:14:33 +07:00
Denozordec ce747673fd feat(web): refactor module display logic and enhance UI components
- Introduced new utility functions for date formatting and module interval labeling.
- Replaced inline badge variant logic with a dedicated function for improved readability.
- Updated imports to streamline component usage and enhance maintainability.
- Enhanced the overall structure of the module display for better clarity and user experience.
2026-05-20 12:10:11 +07:00
Denozordec e558570967 feat(web): enhance monitoring page with new status tracking and UI components
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m36s
- Refactored monitoring page to utilize new UI components from the core library.
- Added detailed job status tracking and error handling features.
- Improved overall layout and responsiveness of the monitoring interface.
- Introduced new derived states for better management of health and job statuses.
- Updated imports to streamline component usage and enhance maintainability.
2026-05-20 11:41:58 +07:00
DenozordecandCursor b6b081b04f style(web): format app-version.svelte for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m45s
Импорт в одну строку — иначе падает npm run lint в CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 11:21:13 +07:00
DenozordecandCursor 13b4c20148 fix(web): show semver on monitoring from legacy /v1/version
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Failing after 35s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
На проде API отдаёт только api_version и git_sha=unknown без поля version;
карточка показывала SHA вместо semver. Общий хелпер version-info.ts:
version → api_version, placeholder SHA не в заголовке.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 11:13:15 +07:00
Denozordec 700d415422 chore: update CI workflow and release process
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 30s
CI / go (push) Successful in 25s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 19s
- Replaced `@semantic-release/git` with `@semantic-release/exec` for handling release commands.
- Updated `.releaserc.json` to reflect changes in release command execution.
- Modified CI workflow to streamline the release process, including tagging and Docker image publishing.
- Removed obsolete publish workflow and integrated its functionality into the main CI workflow.
- Enhanced documentation to clarify the new release process and CI pipeline structure.
2026-05-20 01:05:22 +07:00
semantic-release-bot 6ca34b9008 chore(release): 1.0.0 [skip ci]
## 1.0.0 (2026-05-19)

* feat: add @tanstack/table-core dependency and integrate module name mapping in operations page. Upda ([b140dcb](http://192.168.100.56:3000/denozord/EvoBGP/commit/b140dcb))
* feat: add @types/node and undici-types to devDependencies in package.json and package-lock.json for  ([d92a06c](http://192.168.100.56:3000/denozord/EvoBGP/commit/d92a06c))
* feat: add aggregated router-lists catalog endpoint and update module listing filters ([c811c43](http://192.168.100.56:3000/denozord/EvoBGP/commit/c811c43))
* feat: add auto-updater service for Docker stack with configurable settings ([ae9fbe3](http://192.168.100.56:3000/denozord/EvoBGP/commit/ae9fbe3))
* feat: add bird configuration preview functionality with tabbed interface for fragments and prefixes. ([1969a00](http://192.168.100.56:3000/denozord/EvoBGP/commit/1969a00))
* feat: add BIRD status endpoint and integrate birdc checks into job processing. Enhance UI to display ([94a4c6a](http://192.168.100.56:3000/denozord/EvoBGP/commit/94a4c6a))
* feat: add CSV import and export functionality for module entries. Implement endpoints for exporting  ([bae2d68](http://192.168.100.56:3000/denozord/EvoBGP/commit/bae2d68))
* feat: add debug logging and optimize database queries in Postgres repository ([6d0e214](http://192.168.100.56:3000/denozord/EvoBGP/commit/6d0e214))
* feat: add diagnostic log endpoint for revisions and implement download functionality ([2260ecf](http://192.168.100.56:3000/denozord/EvoBGP/commit/2260ecf))
* feat: add enabled state to BGP peer types and UI. Implement peer enabling toggle in the network page ([80965ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/80965ad))
* feat: add last refreshed timestamp to modules and CDN sources ([1e13ef9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e13ef9))
* feat: add live BGP protocol state tracking to peer listing ([8a50ba4](http://192.168.100.56:3000/denozord/EvoBGP/commit/8a50ba4))
* feat: add optional name field to BGP peer creation and update UI ([1a61ae2](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a61ae2))
* feat: add optional name field to DoH profile schema and types ([6bee5ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/6bee5ad))
* feat: add smart aggregation for IPv4 CIDR prefixes in refresh pipeline. Implement functionality to a ([0428b83](http://192.168.100.56:3000/denozord/EvoBGP/commit/0428b83))
* feat: add strict bind configuration to BGP templates. Enhance BGP template rendering by including 's ([07f2ce5](http://192.168.100.56:3000/denozord/EvoBGP/commit/07f2ce5))
* feat: add tenant refresh functionality to HTTP API and job processing ([7a2b015](http://192.168.100.56:3000/denozord/EvoBGP/commit/7a2b015))
* feat: auto-refresh non-IP module updates and add full project manual ([1e77e1a](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e77e1a))
* feat: enhance API client with pagination and new data fetching functions. Implement `apiPageAll` for ([9f0b128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f0b128))
* feat: enhance BGP template and peer configuration handling. Introduce policies_json parameter for lo ([be8e7b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/be8e7b5))
* feat: enhance CDN source management with last refreshed timestamp ([4776434](http://192.168.100.56:3000/denozord/EvoBGP/commit/4776434))
* feat: enhance community attribute handling in BIRD format. Add validation for community route bodies ([b541794](http://192.168.100.56:3000/denozord/EvoBGP/commit/b541794))
* feat: enhance community label handling and improve UI localization across operations pages. Introduc ([53fd527](http://192.168.100.56:3000/denozord/EvoBGP/commit/53fd527))
* feat: enhance deployment configuration for secure Web UI with Traefik integration ([4acd685](http://192.168.100.56:3000/denozord/EvoBGP/commit/4acd685))
* feat: enhance DoH profile management and resolver policy in modules ([34ecc5c](http://192.168.100.56:3000/denozord/EvoBGP/commit/34ecc5c))
* feat: enhance EvoBGP with new command-line options for the evobgp-agent, including a watch command f ([6a55f72](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a55f72))
* feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and  ([bf52b21](http://192.168.100.56:3000/denozord/EvoBGP/commit/bf52b21))
* feat: enhance job detail handling in UI with caching and loading states. Introduce SvelteMap for job ([05bc5c4](http://192.168.100.56:3000/denozord/EvoBGP/commit/05bc5c4))
* feat: enhance job detail UI with improved overflow handling and layout adjustments. Update card comp ([67ce135](http://192.168.100.56:3000/denozord/EvoBGP/commit/67ce135))
* feat: enhance job filtering and display functionality in operations page. Introduce job search and f ([e1c4cbc](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1c4cbc))
* feat: enhance job processing with detailed logging and UI improvements. Introduce revision log entri ([c48cc4e](http://192.168.100.56:3000/denozord/EvoBGP/commit/c48cc4e))
* feat: enhance monitoring page with detailed status tracking and error handling ([458a885](http://192.168.100.56:3000/denozord/EvoBGP/commit/458a885))
* feat: enhance observability with prefix aggregation metrics ([07b97bd](http://192.168.100.56:3000/denozord/EvoBGP/commit/07b97bd))
* feat: enhance peer neighbor handling by introducing NormalizePeerNeighborString function. Update Cre ([80f3a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/80f3a8d))
* feat: enhance RefreshModule to aggregate prefixes from all enabled tenant modules. Update documentat ([431c6a9](http://192.168.100.56:3000/denozord/EvoBGP/commit/431c6a9))
* feat: enhance RefreshModule to support legacy ASN resolution and improve prefix handling. Introduce  ([e1aaa18](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1aaa18))
* feat: enhance settings page layout and section management ([d91667f](http://192.168.100.56:3000/denozord/EvoBGP/commit/d91667f))
* feat: enhance UI components with improved styling and layout adjustments. Update button sizes, text  ([6a72842](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a72842))
* feat: enhance worker functionality by adding job queuing for deploy_apply. Introduce a Registry fiel ([42d956d](http://192.168.100.56:3000/denozord/EvoBGP/commit/42d956d))
* feat: implement AS holder name resolution and enhance ASEntry structure. Add ASHolderName function t ([c958d5a](http://192.168.100.56:3000/denozord/EvoBGP/commit/c958d5a))
* feat: implement bulk selection and deletion for modules and entries. Add functionality for selecting ([e816d17](http://192.168.100.56:3000/denozord/EvoBGP/commit/e816d17))
* feat: implement CDN source preview functionality and enhance data handling. Add a new endpoint for p ([e20c9f3](http://192.168.100.56:3000/denozord/EvoBGP/commit/e20c9f3))
* feat: implement countAll function for API data retrieval ([40c8a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/40c8a8d))
* feat: implement domain resolution via DoH in refresh pipeline. Enhance the collectModulePrefixRows f ([678bd27](http://192.168.100.56:3000/denozord/EvoBGP/commit/678bd27))
* feat: implement effective peer enabling logic during peer creation. Update CreatePeer methods in Pos ([3e85431](http://192.168.100.56:3000/denozord/EvoBGP/commit/3e85431))
* feat: implement module refresh job management with concurrency control. Add CountOtherActiveModuleRe ([149fb0a](http://192.168.100.56:3000/denozord/EvoBGP/commit/149fb0a))
* feat: implement peer reconciliation job for BGP peers ([3b17228](http://192.168.100.56:3000/denozord/EvoBGP/commit/3b17228))
* feat: implement revision retention settings and pruning logic ([4f50350](http://192.168.100.56:3000/denozord/EvoBGP/commit/4f50350))
* feat: implement theme selection and automatic theme application based on user preference. Update App ([b70a643](http://192.168.100.56:3000/denozord/EvoBGP/commit/b70a643))
* feat: implement versioning and release management in the project ([bb334b1](http://192.168.100.56:3000/denozord/EvoBGP/commit/bb334b1))
* feat: improve BGP protocol summary handling by introducing isBGPProtocolSummaryRow function. Update  ([399622c](http://192.168.100.56:3000/denozord/EvoBGP/commit/399622c))
* feat: improve job detail UI with enhanced spacing and layout adjustments. Update table cell classes  ([97908b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/97908b5))
* feat: improve peer speaker assignment UX in network settings ([f877ec8](http://192.168.100.56:3000/denozord/EvoBGP/commit/f877ec8))
* feat: improve UI components with enhanced overflow handling and layout adjustments. Update card and  ([0ca5542](http://192.168.100.56:3000/denozord/EvoBGP/commit/0ca5542))
* feat: improve UI layout and responsiveness in module and operations pages. Refactor component struct ([233f29b](http://192.168.100.56:3000/denozord/EvoBGP/commit/233f29b))
* feat: redesign settings page with structured controls ([2da6d49](http://192.168.100.56:3000/denozord/EvoBGP/commit/2da6d49))
* feat: refactor materialization logic to group static routes by community in the refresh pipeline. In ([1c5a9c9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1c5a9c9))
* feat: refactor operations page to enhance UI and component structure. Remove unused card components  ([88226f6](http://192.168.100.56:3000/denozord/EvoBGP/commit/88226f6))
* feat: refine BGP template handling and documentation updates. Remove local IP requirements in BGP te ([6266a39](http://192.168.100.56:3000/denozord/EvoBGP/commit/6266a39))
* feat: refine job detail UI with improved layout and scrolling capabilities. Enhance spacing and over ([57c09ac](http://192.168.100.56:3000/denozord/EvoBGP/commit/57c09ac))
* feat: update BGP community structure and related documentation. Refactor BGP community handling by r ([a2cbfac](http://192.168.100.56:3000/denozord/EvoBGP/commit/a2cbfac))
* feat: update BGP template configuration and OpenAPI documentation. Change import settings from 'all' ([133ea93](http://192.168.100.56:3000/denozord/EvoBGP/commit/133ea93))
* feat: update BIRD installation process to build from source. Modify CI workflow to ensure BIRD 2.14  ([e2002ba](http://192.168.100.56:3000/denozord/EvoBGP/commit/e2002ba))
* feat: update CI configuration and Dockerfiles for improved build efficiency ([80229b3](http://192.168.100.56:3000/denozord/EvoBGP/commit/80229b3))
* feat: update collectModulePrefixRows to support prior snapshots and enhance CDN/domain prefix collec ([ee364c8](http://192.168.100.56:3000/denozord/EvoBGP/commit/ee364c8))
* feat: update Go module dependencies and enhance DNS resolution in refresh pipeline. Upgrade Go versi ([655d750](http://192.168.100.56:3000/denozord/EvoBGP/commit/655d750))
* feat: update OpenAPI documentation and internal logic for BIRD settings. Enhance the description of  ([62bf987](http://192.168.100.56:3000/denozord/EvoBGP/commit/62bf987))
* feat: update RevisionDiff type and enhance OperationsDiffTab component for better diff handling. Int ([4aaaa30](http://192.168.100.56:3000/denozord/EvoBGP/commit/4aaaa30))
* feat: update Traefik deployment configuration for Let's Encrypt and logging ([39ba976](http://192.168.100.56:3000/denozord/EvoBGP/commit/39ba976))
* docs: add router-lists-ui integration documentation to README ([b7a8aab](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7a8aab))
* docs: add Web UI section to EvoBGP architecture plan, detailing Svelte and shadcn-svelte integration ([272542b](http://192.168.100.56:3000/denozord/EvoBGP/commit/272542b))
* docs: enhance EvoBGP architecture plan with new risk mitigation strategies, testing matrix for BIRD2 ([3992d01](http://192.168.100.56:3000/denozord/EvoBGP/commit/3992d01))
* docs: enhance README and CI workflow documentation for Docker image building and publishing process, ([9eb3e33](http://192.168.100.56:3000/denozord/EvoBGP/commit/9eb3e33))
* docs: enhance README and CI workflow to clarify Docker image build conditions and Gitea Container Re ([c6687b2](http://192.168.100.56:3000/denozord/EvoBGP/commit/c6687b2))
* docs: openapi.html со встроенным Redoc; инструкция для Gitea и скрипт сборки ([a754e1d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a754e1d))
* docs: update AGENTS and README to include guidelines for Conventional Commits ([4c23232](http://192.168.100.56:3000/denozord/EvoBGP/commit/4c23232))
* docs: update AGENTS and README with engineering rules and guidelines ([aff27e8](http://192.168.100.56:3000/denozord/EvoBGP/commit/aff27e8))
* docs: update architecture and API documentation to clarify job processing, in-memory registry usage, ([acc0608](http://192.168.100.56:3000/denozord/EvoBGP/commit/acc0608))
* docs: update EvoBGP architecture plan with detailed REST API section, including general agreements,  ([1a86d6c](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a86d6c))
* docs: update EvoBGP architecture plan with new repository structure, refined sections, and detailed  ([4480d64](http://192.168.100.56:3000/denozord/EvoBGP/commit/4480d64))
* docs: update README and CI workflow to clarify Docker workspace mounting for bird2 job ([6be46db](http://192.168.100.56:3000/denozord/EvoBGP/commit/6be46db))
* docs: update README and CI workflow to clarify Gitea Registry login process and improve Docker image ([f1b33b9](http://192.168.100.56:3000/denozord/EvoBGP/commit/f1b33b9))
* docs: update README and Docker Compose configurations to clarify usage of pre-built images from the  ([7fac79c](http://192.168.100.56:3000/denozord/EvoBGP/commit/7fac79c))
* docs: update README to include information about the EvoBGP web interface, linking to quickstart and ([5d21f01](http://192.168.100.56:3000/denozord/EvoBGP/commit/5d21f01))
* docs: статическая Redoc-страница OpenAPI (для просмотра в Gitea без встроенного рендера) ([5c9d2e2](http://192.168.100.56:3000/denozord/EvoBGP/commit/5c9d2e2))
* docs: убрать типографское тире и невидимый Unicode из текстов API ([fbe9cd3](http://192.168.100.56:3000/denozord/EvoBGP/commit/fbe9cd3))
* refactor: adjust BootstrapWorkers function call in server.go to change the order of returned values, ([032fa99](http://192.168.100.56:3000/denozord/EvoBGP/commit/032fa99))
* refactor: consolidate module interval display logic and improve edit form handling ([1f85654](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f85654))
* refactor: enhance bird metrics polling with context support ([f6b94a4](http://192.168.100.56:3000/denozord/EvoBGP/commit/f6b94a4))
* refactor: enhance CI workflow for granular change detection by module and improve idempotency key ge ([54eaad3](http://192.168.100.56:3000/denozord/EvoBGP/commit/54eaad3))
* refactor: enhance job status checks in worker tests ([6107c3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/6107c3e))
* refactor: enhance JSON struct tags for various data models in backend and memory store. Update field ([4a860a0](http://192.168.100.56:3000/denozord/EvoBGP/commit/4a860a0))
* refactor: enhance module display and input handling in module pages ([25374a6](http://192.168.100.56:3000/denozord/EvoBGP/commit/25374a6))
* refactor: enhance module interval label logic and add reset buttons in edit forms ([b99fc17](http://192.168.100.56:3000/denozord/EvoBGP/commit/b99fc17))
* refactor: improve code consistency and formatting across components ([c263fd5](http://192.168.100.56:3000/denozord/EvoBGP/commit/c263fd5))
* refactor: improve handling of deployable fragments in ApplyRevision and buildPreviewFragments functi ([6932271](http://192.168.100.56:3000/denozord/EvoBGP/commit/6932271))
* refactor: improve module save functionality and nullable value handling ([05ec176](http://192.168.100.56:3000/denozord/EvoBGP/commit/05ec176))
* refactor: improve PATCH handling for module updates with nullable field support ([e700f90](http://192.168.100.56:3000/denozord/EvoBGP/commit/e700f90))
* refactor: optimize job listing and counting logic for concurrency safety. Separate tenant filtering  ([d83efe2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d83efe2))
* refactor: phase 2 structural alignment — reports, importer, nodecli, pagination ([250cdea](http://192.168.100.56:3000/denozord/EvoBGP/commit/250cdea))
* refactor: remove strict bind configuration from BGP templates and update related logic. Eliminate 's ([482eeb1](http://192.168.100.56:3000/denozord/EvoBGP/commit/482eeb1))
* refactor: streamline Docker build process in CI configuration and documentation ([6025f38](http://192.168.100.56:3000/denozord/EvoBGP/commit/6025f38))
* refactor: streamline edit form handling for community and DoH profile selections ([1765ab8](http://192.168.100.56:3000/denozord/EvoBGP/commit/1765ab8))
* refactor: streamline module refresh process and enhance revision handling ([10e2415](http://192.168.100.56:3000/denozord/EvoBGP/commit/10e2415))
* refactor: switch BIRD installation from source to Ubuntu package. Update CI workflow and Dockerfiles ([0b3ceaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/0b3ceaa))
* refactor: update AS entry handling in API and database to support ASN-only entries. Remove prefix fi ([1db6b5b](http://192.168.100.56:3000/denozord/EvoBGP/commit/1db6b5b))
* refactor: update Docker build context and paths in CI configuration and documentation ([7d52a4a](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d52a4a))
* refactor: update Docker build variable syntax in CI configuration and documentation ([27e01d0](http://192.168.100.56:3000/denozord/EvoBGP/commit/27e01d0))
* refactor: update job kind display logic in schedule page ([dc1f4a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc1f4a5))
* refactor: update ListModules and GetModule methods in Postgres repository to include cron expression ([73d0003](http://192.168.100.56:3000/denozord/EvoBGP/commit/73d0003))
* refactor: update Switch component styles and structure for improved accessibility and responsiveness ([d4282c2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d4282c2))
* refactor: update worker processes in EvoBGP to accept dependencies for shared store and job registry ([b7968db](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7968db))
* ci: add conditional job for OpenAPI linting based on file changes; update README with workflow detai ([3adfc49](http://192.168.100.56:3000/denozord/EvoBGP/commit/3adfc49))
* ci: phase 4 automation — web job, lint-httpapi, golangci ([64516c3](http://192.168.100.56:3000/denozord/EvoBGP/commit/64516c3))
* ci: update README and CI workflow to improve bird2 job installation process and clarify Docker usage ([eddc6b8](http://192.168.100.56:3000/denozord/EvoBGP/commit/eddc6b8))
* chore: phase 3 code quality — pipeline prefixes, godoc baseline ([ea65451](http://192.168.100.56:3000/denozord/EvoBGP/commit/ea65451))
* chore: update CI configuration to disable filesystem entitlement for Docker builds ([9ab7de6](http://192.168.100.56:3000/denozord/EvoBGP/commit/9ab7de6))
* chore: update package dependencies and enhance AppShell component with new navigation items and tool ([7d85fef](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d85fef))
* chore: upgrade Go version to 1.24 in Dockerfiles for evobgp-agent and gobinary. This change ensures  ([4ba5b03](http://192.168.100.56:3000/denozord/EvoBGP/commit/4ba5b03))
* fix: adjust Postgres resource limits and healthcheck parameters. Increase memory limit to 256M and m ([20e674c](http://192.168.100.56:3000/denozord/EvoBGP/commit/20e674c))
* fix: improve input handling and validation for settings fields ([1386b63](http://192.168.100.56:3000/denozord/EvoBGP/commit/1386b63))
* fix: improve sidebar and navigation layout in AppShell component ([9f02128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f02128))
* fix: update BGP configuration in test scenarios to use new IPv6 addresses ([9fd9745](http://192.168.100.56:3000/denozord/EvoBGP/commit/9fd9745))
* fix: update bird build script and Dockerfile to include libncurses dependency ([0cb5a7d](http://192.168.100.56:3000/denozord/EvoBGP/commit/0cb5a7d))
* fix: update bird build script and Dockerfile to include readline dependency ([35ed79b](http://192.168.100.56:3000/denozord/EvoBGP/commit/35ed79b))
* fix: update bird build script and Dockerfile to remove readline dependency ([fefadac](http://192.168.100.56:3000/denozord/EvoBGP/commit/fefadac))
* fix: update deployment configuration for Traefik and secure Web UI ([00bd037](http://192.168.100.56:3000/denozord/EvoBGP/commit/00bd037))
* fix: update Docker build commands in CI configuration and documentation ([b5061a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/b5061a5))
* fix(httpapi): phase 1 compliance — ERR-01, SEC-04, CDN client, scheduler docs ([1dd7135](http://192.168.100.56:3000/denozord/EvoBGP/commit/1dd7135))
* test: add CDN prefetch tests for 304 responses and snapshot handling ([a536a2d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a536a2d))
* test: add validation for 'strict bind on' in BGP rendering tests. Enhance BGP test cases to ensure ' ([dc5e20e](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc5e20e))
* docker: refactor cache handling in Docker bake configuration ([5e59a1c](http://192.168.100.56:3000/denozord/EvoBGP/commit/5e59a1c))
* docker: simplify web image targets in Docker bake configuration ([765a805](http://192.168.100.56:3000/denozord/EvoBGP/commit/765a805))
* docker: update web image build process and README ([92f6de8](http://192.168.100.56:3000/denozord/EvoBGP/commit/92f6de8))
* web(ui): app layout — sidebar, mobile sheet, topbar, PageHeader ([2d78c81](http://192.168.100.56:3000/denozord/EvoBGP/commit/2d78c81))
* web(ui): AppDataTable pattern and modules pilot ([b992301](http://192.168.100.56:3000/denozord/EvoBGP/commit/b992301))
* web(ui): design tokens — spacing and component scale ([ac635e9](http://192.168.100.56:3000/denozord/EvoBGP/commit/ac635e9))
* web(ui): feedback — confirm dialog, skeletons, README ([87309cf](http://192.168.100.56:3000/denozord/EvoBGP/commit/87309cf))
* web(ui): form patterns — FormField, formsnap, directories pilot ([77a2cfd](http://192.168.100.56:3000/denozord/EvoBGP/commit/77a2cfd))
* web(ui): foundation — ui/core layout, shadcn primitives, notify ([00a2a44](http://192.168.100.56:3000/denozord/EvoBGP/commit/00a2a44))
* web(ui): migrate dashboard, schedule, monitoring to ui patterns ([57dcf30](http://192.168.100.56:3000/denozord/EvoBGP/commit/57dcf30))
* web(ui): migrate network to ui patterns ([3a3056b](http://192.168.100.56:3000/denozord/EvoBGP/commit/3a3056b))
* web(ui): migrate operations and module detail to ui patterns ([1f9930d](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f9930d))
* web(ui): migrate settings to ui patterns ([9808aca](http://192.168.100.56:3000/denozord/EvoBGP/commit/9808aca))
* Enhance EvoBGP architecture plan by refining the overview, updating todos with detailed descriptions ([30efdbf](http://192.168.100.56:3000/denozord/EvoBGP/commit/30efdbf))
* init ([cbf3aaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/cbf3aaa))
* Refine EvoBGP architecture plan with updated database schema details, improved descriptions for comp ([431ef3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/431ef3e))
* Update EvoBGP architecture plan with new overview and todos. Refined descriptions for control-plane  ([31ff5bb](http://192.168.100.56:3000/denozord/EvoBGP/commit/31ff5bb))
2026-05-19 17:58:24 +00:00
Denozordec bb334b10f5 feat: implement versioning and release management in the project
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 28s
CI / go (push) Successful in 26s
CI / bird2 (push) Successful in 15s
- Added automatic versioning based on git tags using semantic-release in Gitea Actions.
- Introduced new API endpoints `GET /version` and `GET /v1/version` to expose build metadata.
- Updated Docker build process to include version and build time information in Go binaries.
- Enhanced documentation with details on release processes and versioning guidelines.
- Integrated version display in the web application for better user visibility.
2026-05-20 00:56:02 +07:00
Denozordec 4c23232c4e docs: update AGENTS and README to include guidelines for Conventional Commits
- Added a section on Conventional Commits in AGENTS.md, detailing the process for generating commit messages.
- Enhanced README.md with references to the Conventional Commits rules and the necessary scripts for generating commit messages.
- Clarified the format for commit messages, specifying the language requirements for headers and bodies.
2026-05-20 00:48:21 +07:00
106 changed files with 15797 additions and 4358 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["@commitlint/config-conventional"]
}
+37
View File
@@ -0,0 +1,37 @@
# Сгенерировать commit message (EvoBGP)
Сгенерируй сообщение коммита для **текущих staged-изменений**. Не выполняй `git commit`, если пользователь явно не просил закоммитить.
## Обязательный workflow (MUST)
1. Прочитай скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md).
2. Следуй правилу [`.cursor/rules/conventional-commits.mdc`](../rules/conventional-commits.mdc).
3. **Первым вызовом Shell** из корня репозитория:
```powershell
powershell -NoProfile -File scripts/commit/staged-context.ps1
```
4. Строй текст **только** по JSON stdout (`groups`, `stat`, `diff_excerpt`). Exit `1` → index пуст, сообщи пользователю.
5. **Запрещено** обходить скрипт через один `git diff --cached`.
## Формат вывода
Для каждого коммита (при auto-split — по одному блоку):
```
<type>(<scope>): <summary in English>
<тело на русском>
```
Плюс пояснение (RU): semver impact (`minor`|`patch`|`none`|`major`), почему выбран type, был ли split.
## Semver (кратко)
- Новая пользовательская возможность → `feat` (minor)
- Починка ожидаемого поведения / баг → `fix` (patch)
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
- Только перестройка без нового поведения → `refactor` (none)
Заголовок — EN, императив, ≤72 символов. Тело — RU.
+178
View File
@@ -0,0 +1,178 @@
---
description: >-
Conventional Commits EvoBGP. Триггеры commit/коммит/закоммить/staged/commit message —
ОБЯЗАТЕЛЬНО сначала Shell scripts/commit/staged-context.ps1, скилл commit-message.
alwaysApply: false
---
# Conventional Commits (EvoBGP)
Полное описание релизного пайплайна: [docs/releasing.md](../../docs/releasing.md).
## Триггеры (применить правило + скилл)
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит», **`/commit-message`** — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
### Не путать с кнопкой ✨ в Source Control
Команда **`cursor.generateGitCommitMessage`** (sparkle в поле commit message) **не** читает Rules, Skills и `staged-context.ps1` — только staged diff и история коммитов ([ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)).
**Замена для EvoBGP:** Agent → `/commit-message` или команда [`.cursor/commands/commit-message.md`](../commands/commit-message.md).
## Обязательный запуск скрипта (MUST)
1. Прочитать скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md).
2. **Первым действием** выполнить Shell (из корня репо):
```powershell
powershell -NoProfile -File scripts/commit/staged-context.ps1
```
3. Сообщение коммита строить **только** по JSON из stdout скрипта (`groups`, `diff_excerpt`, `stat`).
4. **Запрещено** генерировать commit message без успешного (exit 0) запуска скрипта; не заменять скрипт одним `git diff --cached`.
## Формат (строго)
```
<type>(<scope>): <short summary in English>
<тело на русском: что изменено и зачем>
```
- **Заголовок** — только английский; императив, без точки в конце; ≤72 символов.
- **Тело** — только русский; полные предложения; пустая строка после заголовка.
- Запрещены vague-сообщения: `fix bug`, `update code`, `wip`, `misc`.
## Типы (semantic-release)
| type | Когда | Версия |
|------|--------|--------|
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
| `perf` | ускорение без смены API | patch |
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
| `docs` | только документация | — |
| `test` | тесты | — |
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
| `chore` | обслуживание, deps, `.cursor/` | — |
### Выбор type: semver, а не «красивые слова»
**Главный вопрос:** что изменится для пользователя после релиза?
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor`
4. Ускорение без изменения контракта → `perf`
**Не путать с формулировкой diff:**
| В diff / задаче часто пишут | Неверный type | Верный type, если… |
|-----------------------------|---------------|---------------------|
| enhance, improve, polish UI | `feat` | …только чиним сломанное после прошлого PR → `fix` |
| refactor pages, unify tables | `feat` | …новой возможности нет, лишь перенос на AppDataTable → `refactor` |
| follow-up после feat(web) | `feat` | …исправляем баги того же экрана → `fix` |
**Follow-up rule:** коммит сразу после `feat` в той же области, который **не добавляет** новую возможность, а устраняет дефект (effect loop, не открывается dialog, confirm не срабатывает) — **`fix`**, не `feat`.
**Split при смешанном diff:** новая страница/flow → `feat`; отдельным коммитом правки багов → `fix`. Не объединять в один `feat`.
**Breaking changes** — только `feat!` / `fix!` / `BREAKING CHANGE:` когда пользователь **обязан** менять конфиг, API или привычный workflow.
### Обязательно в пояснении агенту
При каждом предложении коммита указать:
- **Semver impact:** `minor` | `patch` | `none` | `major`
- **Почему не другой type** (одно предложение), если diff большой или формулировка двусмысленная
Пример неправильно / правильно:
```
# Плохо — patch-фикс, minor-bump
feat(web): enhance module entry dialogs and selection handling
# Хорошо
fix(web): stop effect loop breaking module action buttons
Исправлен effect_update_depth_exceeded и bind:open у Dialog; кнопки редактирования/удаления снова работают.
```
```
# Плохо — рефакторинг без новой фичи
feat(web): migrate modules list to AppDataTable
# Хорошо — если не было нового user-facing
refactor(web): migrate modules list to AppDataTable
Единый паттерн таблиц; поведение списка модулей без изменений.
```
```
# Хорошо feat — действительно новое
feat(web): add module create dialog on /modules
Диалог создания модуля с POST /v1/modules; раньше создание было только через API.
```
## Breaking changes
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
## Scope (EvoBGP)
Выбирать по доминирующему пути из staged diff:
| Префикс | scope |
|---------|-------|
| `internal/httpapi/` | `httpapi` |
| `internal/store/`, `internal/repository/`, `internal/db/` | `store` |
| `internal/jobs/` | `jobs` |
| `internal/pipeline/` | `pipeline` |
| `internal/birdfmt/` | `birdfmt` |
| `internal/birddeploy/` | `birddeploy` |
| `internal/bundle/`, `internal/signing/` | `bundle` |
| `cmd/` | `cmd` |
| `web/` | `web` |
| `docs/openapi.yaml`, `redocly.yaml` | `openapi` |
| `docs/` (остальное) | `docs` |
| `migrations/` | `db` |
| `.gitea/` | `ci` |
| `deploy/` | `deploy` |
| `.cursor/` | `chore` |
| прочее в корне | `chore` |
`type` определять по **содержимому diff**, не только по пути.
## Multi-change
- Несвязанные области → **отдельные коммиты** (auto-split по скиллу).
- Одна логическая фича через несколько scope (OpenAPI + httpapi + web) → **один** коммит, scope по главной области.
## Обязательный вывод агенту
Для каждого коммита:
**1. Готовое сообщение** (копировать в `git commit -m` / HEREDOC):
```
<type>(<scope>): <summary>
<тело RU>
```
**2. Пояснение (RU):** semver impact (`minor`|`patch`|`none`|`major`); почему выбран type; риск/impact; был ли split.
## Примеры
```
feat(httpapi): add version endpoint and wire web footer
Добавлен GET /v1/version. В Web UI версия в footer берётся из API вместо захардкоженного значения.
```
```
fix(auth): correct token validation edge case
Исправлена ложная 401 при истёкшем refresh-токене с валидной сессией.
```
+6 -4
View File
@@ -15,7 +15,7 @@ alwaysApply: true
**ARCH-01** | MUST | Новая persistence-логика — метод `store.Backend` + реализации в `repository` и `store.Memory`; SQL не в `httpapi`.
*Rationale:* единая абстракция данных.
*Проверка:* grep SQL в `internal/httpapi` — отсутствие; review.
*Проверка:* CI `scripts/lint-httpapi.sh`; grep SQL в `internal/httpapi` — отсутствие.
**ARCH-02** | MUST | HTTP-маршруты только в `internal/httpapi`; регистрация через `http.ServeMux` с паттернами `METHOD /v1/...`.
*Rationale:* один слой REST.
@@ -75,7 +75,7 @@ alwaysApply: true
**STYLE-05** | MUST | HTTP-ошибки — `writeProblem` / `writeJSON` (`application/problem+json` для 4xx/5xx).
*Rationale:* RFC 9457, OpenAPI.
*Проверка:* `problem.go`.
*Проверка:* `problem.go`; CI `scripts/lint-httpapi.sh` (5xx и 4xx store/cdn/csv).
**STYLE-06** | MUST | JSON полей HTTP DTO согласованы с `docs/openapi.yaml`.
*Rationale:* контракт API.
@@ -123,7 +123,7 @@ alwaysApply: true
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
*Проверка:* CI job `bird2`.
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint` (CI web пока не в scope).
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint`; CI job `web` в `.gitea/workflows/ci.yaml`.
*Проверка:* локальные команды.
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
@@ -234,7 +234,9 @@ npx @redocly/cli lint docs/openapi.yaml
# birdfmt: go test ./internal/birdfmt/... -count=1
```
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
**CI (Gitea):** job `web` (check + lint); job `go`: `go vet`, `scripts/lint-httpapi.sh` (ARCH-01, ERR-01), `scripts/check-migrations-pair.sh` (DEP-03), `golangci-lint`, `go test -race`, build `cmd/*`.
**Рекомендуется локально:** `.golangci.yml`; `.pre-commit-config.yaml` (gofmt + prettier web).
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
+143
View File
@@ -0,0 +1,143 @@
---
name: commit-message
description: >-
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
staged, semantic-release, «сгенерируй коммит», git commit, /commit-message: ПЕРВЫМ
делом Shell — scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
---
> **Кнопка ✨ Generate commit message в Source Control** не использует этот скилл и Rules.
> Эквивалент: Agent Chat → **`/commit-message`** или «сгенерируй коммит по staged».
> См. [docs/README.md](../../docs/README.md#сообщения-коммитов-cursor).
# Commit message (EvoBGP)
## Когда применять (сразу читать этот скилл)
Триггеры в промпте пользователя или в плане/AGENTS.md:
- `commit`, `коммит`, `закоммить`, `commit message`, `conventional commit`
- `git commit`, `staged`, `сообщение коммита`, `сгенерируй коммит`
- агент собирается выполнить `git commit` или предложить текст коммита
## Шаг 0 — ОБЯЗАТЕЛЬНО (до любого текста коммита)
**Первый вызов инструментов** в этой задаче — Shell из корня репозитория:
```powershell
powershell -NoProfile -File scripts/commit/staged-context.ps1
```
| Правило | Деталь |
|---------|--------|
| **MUST** | Запустить скрипт до генерации заголовка/тела коммита |
| **MUST NOT** | Строить сообщение только по `git diff --cached` / `git status` без скрипта |
| **MUST NOT** | Пропускать скрипт, даже если diff «и так понятен» |
| Exit `1` | Index пуст — сообщить пользователю, **не коммитить** |
| Exit `0` | Разобрать JSON stdout: `staged_count`, `groups[]` (`scope`, `files`, `stat`, `diff_excerpt`) |
Дополнительно: [.cursor/rules/conventional-commits.mdc](../../rules/conventional-commits.mdc).
## Слияние групп (одна фича)
Скрипт группирует **только по путям**. Перед split проверьте логическую связность:
**Объединить в один коммит**, если это одна задача:
- `docs/openapi.yaml` + `internal/httpapi/` (+ опционально `web/`) — один endpoint/контракт;
- `migrations/` + `internal/store/` / `repository/` — одна схема;
- правки теста рядом с кодом той же фичи.
При объединении: один scope (доминирующий пакет, часто `httpapi` или `openapi`), один type, одно тело RU.
**Auto-split** — если группы **не связаны** (например `web/` + `internal/birdfmt/` без общего смысла): **отдельный коммит на группу**, без вопроса пользователю.
## Порядок коммитов при split
1. `openapi`
2. `httpapi`, `store`, `jobs`
3. `pipeline`, `birdfmt`, `birddeploy`, `bundle`
4. `web`
5. `ci`, `deploy`, `db`
6. `docs`
7. `chore`, `cmd`
## Алгоритм auto-split (уровень файлов)
1. `git status` и `git diff --cached --stat` — зафиксировать полный список staged-файлов.
2. `git reset HEAD` — снять всё из index (working tree не трогать).
3. Для каждой (объединённой) группы по порядку выше:
- `git add -- <paths…>`
- Сгенерировать сообщение по правилу conventional-commits.
- Закоммитить (см. ниже).
4. После всех коммитов — `git status` для проверки.
**Ограничение:** частичный stage одного файла с разной семантикой — предупредить; split по hunk'ам не делать; предложить разнести правки по файлам.
## Создание коммита
Только если пользователь **явно** просил закоммитить. Иначе — только вывести готовые сообщения.
```powershell
git commit -m "$( @'
<type>(<scope>): <summary in English>
<Тело на русском.>
'@ )"
```
В PowerShell для многострочного тела используйте here-string как выше или `-m` для заголовка и `-m` для тела.
Перед коммитом: `git status`, `git diff --cached` для группы — убедиться, что stage соответствует сообщению.
## Генерация текста
По `groups[].diff_excerpt`, `stat`, `files`:
### Шаг A — semver (до выбора type)
| Вопрос | Если «да» → |
|--------|-------------|
| Пользователь получает **новую** возможность? | `feat` (minor) |
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
| Только скорость, контракт тот же? | `perf` (patch) |
| Только структура кода/UI, поведение то же? | `refactor` (none) |
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
**Запрещено** по умолчанию ставить `feat` для «большого diff» в `web/` — type по **semver impact**, не по объёму.
### Шаг B — type, scope, текст
- **type** — результат шага A, не «chore по умолчанию» и не `feat` из-за слова enhance.
- **scope** — из JSON группы или доминирующий при merge.
- **summary** — конкретный, английский, императив; для `fix` — что **починено** (`fix broken …`, `prevent … loop`).
- **body** — русский: что, зачем, edge cases, breaking impact.
## Вывод пользователю
Для **каждого** коммита:
### 1. Готовое сообщение
```
<type>(<scope>): <summary>
<тело>
```
### 2. Пояснение (RU)
- **Semver impact:** `minor` | `patch` | `none` | `major` — и почему не другой type.
- Риски и impact.
- Split: сколько коммитов и почему.
## Не смешивать
Один commit message — одна primary intent. Не объединять несвязанный `fix` и `feat` в один заголовок.
## Ссылки
- Правило: `.cursor/rules/conventional-commits.mdc`
- Скрипт: `scripts/commit/staged-context.ps1`
- Просмотр групп: `powershell -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`
+29 -51
View File
@@ -1,75 +1,53 @@
# Gitea Actions
В репозитории включён workflow [workflows/ci.yaml](workflows/ci.yaml).
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
Job **changes** вычисляет два флага:
| Job | PR | push в main |
|-----|-----|-------------|
| changes, openapi, web, go, bird2 | quality gates | quality gates |
| commitlint | да | — |
| **release** | — | semantic-release + docker push (один run) |
- **`openapi`** — в диффе есть `docs/openapi.yaml` или `redocly.yaml` → запускается **openapi** (Redocly lint).
- **`code`** — в диффе есть **хотя бы один файл вне «только документация»** → запускаются **go**, **bird2** и при push в main/master **docker-images**.
Подробнее: [docs/releasing.md](../docs/releasing.md).
Если изменены **только** файлы из списка ниже, **`code=false`**: Go-тесты, bird2 и сборка образов **не** запускаются; при необходимости всё ещё выполняется **openapi**, если трогали OpenAPI или `redocly.yaml`.
## CI (quality gates)
**Список путей «только документация» (без полной сборки):** всё под `docs/`, корневой `README.md`, `web/README.md`, `.gitea/README.md`, `redocly.yaml`.
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml` → `go` / `openapi` и т.д. (см. `ci.yaml`).
Любой другой путь (в т.ч. `.gitea/workflows/ci.yaml`, `go.mod`, `web/package.json`, `deploy/**`, `**/*.go`) включает полный пайплайн.
На **pull request** — **commitlint** (Conventional Commits).
Требования к runner:
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
- Метка `ubuntu-latest` (или измените `runs-on` под свои labels).
- Job `bird2` ставит пакет **bird2** из репозитория **Ubuntu** на runner (как образ `evobgp-bird2` на `ubuntu:noble`) и вызывает `bird -c … -p` по файлам в checkout (без вложенного Docker: иначе bind-mount `/workspace/...` часто не виден демону Docker на хосте).
- Job **docker-images** требует **Docker** и (для push) доступ к Container Registry Gitea с runner’а.
## Release (job в ci.yaml)
## Сборка и публикация образов (`docker-images`)
После успешных quality gates на **push в main** job **release**:
Запускается только при **push** в ветки `main` или `master` (после успешного job `go`). Собираются образы из `deploy/docker/*` (все бинарники через `gobinary`, плюс `evobgp-web`, `evobgp-agent`, `evobgp-bird2`).
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
### Секреты репозитория (для push в реестр)
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
Хост Container Registry в workflow **зашит**: **`git.shts.su`** (`docker/login-action` и теги образов).
### Секреты
В настройках репозитория Gitea: **Settings → Actions → Secrets** (или аналог):
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
| Секрет | Назначение |
|--------|------------|
| `ACTIONS_PAT` | (Опционально) PAT с правом **записи пакетов**. Если не задан, для `docker login` используется **`gitea.token`** (нужны права у workflow на пакеты). |
Вход в реестр в workflow:
- **registry:** `git.shts.su`;
- **username:** `${{ gitea.actor }}`;
- **password:** `${{ secrets.ACTIONS_PAT || gitea.token }}`.
### Почему шаг «Log in to Gitea Registry» мог быть skipped
Раньше в `if:` стояло **`secrets.ACTIONS_PAT != ''`**. В **Gitea act_runner** такая проверка секрета в условии шага часто **всегда ложна**, из‑за этого логин **всегда skipped**. Сейчас условие только `docker_push == 'true'` (всегда при нормальном шаге **Prepare**), пароль: **`secrets.ACTIONS_PAT || gitea.token`**.
Если после правки `docker login` падает с 401/403 — выдайте job-токену или PAT право **записи в Container Registry** (в настройках Gitea / организации / репозитория, в зависимости от версии).
Если **`gitea.token`** в выражениях недоступен, в [ci.yaml](workflows/ci.yaml) замените пароль на `${{ secrets.ACTIONS_PAT || github.token }}`.
### Имена образов и теги
Формат:
### Теги образов
```text
git.shts.su/<owner_lowercase>/<имя>:<тег>
git.shts.su/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha>
```
где `<имя>` — одно из: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all` (UI с прокси на `evobgp-all` для профиля Compose `microvps-full`), `evobgp-agent`, `evobgp-bird2`.
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
Теги: `latest`, короткий SHA (7 символов), `sha-<полный_commit_sha>`.
Пример pull:
Пример:
```bash
docker pull git.shts.su/myuser/evobgp-api:latest
docker pull git.shts.su/myuser/evobgp-api:1.2.3
```
Полная таблица образов, тегов, ссылок на страницы пакетов в Gitea и минимальный `docker run` для сервера без сборки — в [docs/quickstart.md](../docs/quickstart.md) (раздел «Готовые образы без сборки»).
Убедитесь, что в **Gitea** включены пакеты (Container Registry) и у токена есть права на создание/обновление образов. Другой хост реестра — правьте **`registry:`** и префикс **`IMG=`** в [ci.yaml](workflows/ci.yaml).
### Примечания
- Сборка с платформой `linux/amd64`. При необходимости добавьте `linux/arm64` и multi-arch в `docker buildx build`.
- Для PR job **docker-images** не выполняется (только push в main/master), чтобы не публиковать образы из чужих форков без явной политики.
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
+142 -128
View File
@@ -8,10 +8,9 @@ on:
jobs:
# ---------------------------------------------------------------------------
# Гранулярная детекция изменений по модулям.
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*)
# поднимает все флаги, чтобы гарантировать полный прогон.
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
# ---------------------------------------------------------------------------
changes:
runs-on: ubuntu-latest
@@ -40,6 +39,23 @@ jobs:
docker_web=false
docker_bird=false
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
set_all_flags_true() {
openapi=true
go=true
web=true
bird_conf=true
docker_go=true
docker_web=true
docker_bird=true
}
write_outputs() {
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
@@ -52,61 +68,84 @@ jobs:
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
openapi=true; go=true; web=true; bird_conf=true
docker_go=true; docker_web=true; docker_bird=true
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
echo "$v=true" >> "$GITHUB_OUTPUT"
done
echo "No parent commit — full pipeline"
set_all_flags_true
write_outputs
echo "No parent commit — full pipeline (all modules)"
exit 0
fi
fi
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
go=true; web=true
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
echo "Empty diff — safe fallback: go=true web=true"
set_all_flags_true
write_outputs
echo "Empty diff — full pipeline fallback"
exit 0
fi
ci_changed=false
full_pipeline=false
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*) ci_changed=true ;;
docs/openapi.yaml|redocly.yaml) openapi=true ;;
web/README.md) ;; # doc-only
web/*) web=true ;;
deploy/bird/*) bird_conf=true ;;
deploy/docker/bird/*) docker_bird=true; docker_go=true ;;
deploy/docker/gobinary/*) docker_go=true ;;
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;;
deploy/docker/evobgp-agent/*) docker_go=true ;;
deploy/docker/evobgp-web/*) docker_web=true ;;
deploy/docker/bird2/*) docker_bird=true ;;
go.mod|go.sum|go.work) go=true ;;
*.go) go=true ;;
cmd/*|internal/*) go=true ;;
# CI/CD инфраструктура — все узлы quality gates
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
full_pipeline=true
;;
docs/openapi.yaml|redocly.yaml)
openapi=true
;;
docs/api.md|docs/access.md)
openapi=true
go=true
;;
web/README.md|web/components.json)
;;
web/*)
web=true
;;
deploy/bird/*)
bird_conf=true
go=true
;;
deploy/compose/*|deploy/docker/*)
docker_go=true
docker_web=true
docker_bird=true
go=true
;;
go.mod|go.sum|go.work)
go=true
;;
migrations/*)
go=true
;;
cmd/*|internal/*|*.go)
go=true
bird_conf=true
;;
docs/*)
go=true
;;
package.json|package-lock.json|.releaserc.json)
full_pipeline=true
;;
*)
go=true
;;
esac
done <<< "$FILES"
if $ci_changed; then
go=true; web=true; bird_conf=true
docker_go=true; docker_web=true; docker_bird=true
if $full_pipeline; then
set_all_flags_true
fi
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
write_outputs
echo "Changed files (first 30):"
printf '%s\n' "$FILES" | head -n 30
echo "--- flags ---"
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird ci=$ci_changed"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
# ---------------------------------------------------------------------------
openapi:
@@ -157,6 +196,14 @@ jobs:
run: go vet ./...
- name: Lint httpapi (ERR-01 / ARCH-01)
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.8
install-mode: goinstall
- name: Test
run: go test ./... -race -count=1
- name: Build all commands
@@ -208,88 +255,99 @@ jobs:
done
# ---------------------------------------------------------------------------
# Docker: все Go-образы одним buildx bake (один go mod download, одна компиляция cmd/*,
# birdc собирается один раз для api/all). Registry cache: evobgp-buildcache:go-buildcache.
# ---------------------------------------------------------------------------
docker-go:
needs: [changes, go]
if: >-
always() &&
needs.changes.result == 'success' &&
needs.go.result != 'failure' &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
(needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true')
commitlint:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare image metadata
id: meta
run: |
set -euo pipefail
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push Go images (bake)
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: ""
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Lint commit messages
run: |
set -euxo pipefail
cd "${{ github.workspace }}/deploy/docker"
sh write-bake-override.sh
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl -f docker-bake.override.hcl go-images --push
npm ci
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
# ---------------------------------------------------------------------------
# Docker: Web (evobgp-web, evobgp-web-all) — один npm ci (кэш) + два nginx-тега.
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
# ---------------------------------------------------------------------------
docker-web:
needs: [changes]
release:
needs: [changes, openapi, web, go, bird2]
if: >-
always() &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
(needs.changes.outputs.web == 'true' || needs.changes.outputs.docker_web == 'true')
needs.changes.result == 'success' &&
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shts.su
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
if [ -f .release-version ]; then
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
echo "No releasable commits — skipping image publish"
fi
- name: Set up Docker Buildx
if: steps.rel.outputs.released == 'true'
uses: docker/setup-buildx-action@v3
- name: Prepare image metadata
if: steps.rel.outputs.released == 'true'
id: meta
run: |
set -euo pipefail
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push Web images (bake)
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
CACHE_REF_GO: ""
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
@@ -297,49 +355,5 @@ jobs:
cd "${{ github.workspace }}/deploy/docker"
sh write-bake-override.sh
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl -f docker-bake.override.hcl web-images --push
-f docker-bake.hcl -f docker-bake.override.hcl default --push
# ---------------------------------------------------------------------------
# Docker: BIRD2 (evobgp-bird2).
# Собирается только при изменении deploy/bird/ или Dockerfile bird2.
# ---------------------------------------------------------------------------
docker-bird:
needs: [changes]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
(needs.changes.outputs.bird_conf == 'true' || needs.changes.outputs.docker_bird == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare image metadata
id: meta
run: |
set -euo pipefail
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push evobgp-bird2 (bake)
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
CACHE_REF_GO: ""
CACHE_REF_WEB: ""
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
set -euxo pipefail
cd "${{ github.workspace }}/deploy/docker"
sh write-bake-override.sh
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl -f docker-bake.override.hcl evobgp-bird2 --push
+18
View File
@@ -0,0 +1,18 @@
# Root npm (semantic-release, commitlint) — npm ci in CI, never commit deps
node_modules/
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
deploy/docker/docker-bake.override.hcl
# CI-only markers (not committed)
.release-version
CHANGELOG.md
# OS
.DS_Store
Thumbs.db
# Env / secrets (local only)
.env
.env.*
!.env.example
+1
View File
@@ -2,6 +2,7 @@ run:
timeout: 5m
linters:
disable-all: true
enable:
- gofmt
- govet
+19
View File
@@ -0,0 +1,19 @@
# Local hooks (optional): install with `pre-commit install`
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- repo: local
hooks:
- id: prettier-web
name: prettier (web)
entry: bash -c 'cd web && npx prettier --check .'
language: system
files: ^web/
pass_filenames: false
+49
View File
@@ -0,0 +1,49 @@
{
"branches": ["main", "master"],
"tagFormat": "v${version}",
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "ci", "release": "patch" },
{ "breaking": true, "release": "major" }
]
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/exec",
{
"successCmd": "echo ${nextRelease.version} > .release-version"
}
],
[
"@markwylde/semantic-release-gitea",
{
"giteaUrl": "https://git.shts.su",
"assets": [
{
"path": "CHANGELOG.md",
"label": "Changelog"
}
]
}
]
]
}
+11
View File
@@ -36,6 +36,17 @@
- **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи.
- **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины.
## Коммиты (Conventional Commits)
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc).
Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл).
**Кнопка ✨ Generate commit message в Source Control** skill/rule **не** использует. Для сообщений по правилам EvoBGP — Agent Chat → **`/commit-message`** (см. [.cursor/commands/commit-message.md](.cursor/commands/commit-message.md)).
## Команды и среда
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
+150
View File
@@ -0,0 +1,150 @@
## 1.0.0 (2026-05-19)
* feat: add @tanstack/table-core dependency and integrate module name mapping in operations page. Upda ([b140dcb](http://192.168.100.56:3000/denozord/EvoBGP/commit/b140dcb))
* feat: add @types/node and undici-types to devDependencies in package.json and package-lock.json for ([d92a06c](http://192.168.100.56:3000/denozord/EvoBGP/commit/d92a06c))
* feat: add aggregated router-lists catalog endpoint and update module listing filters ([c811c43](http://192.168.100.56:3000/denozord/EvoBGP/commit/c811c43))
* feat: add auto-updater service for Docker stack with configurable settings ([ae9fbe3](http://192.168.100.56:3000/denozord/EvoBGP/commit/ae9fbe3))
* feat: add bird configuration preview functionality with tabbed interface for fragments and prefixes. ([1969a00](http://192.168.100.56:3000/denozord/EvoBGP/commit/1969a00))
* feat: add BIRD status endpoint and integrate birdc checks into job processing. Enhance UI to display ([94a4c6a](http://192.168.100.56:3000/denozord/EvoBGP/commit/94a4c6a))
* feat: add CSV import and export functionality for module entries. Implement endpoints for exporting ([bae2d68](http://192.168.100.56:3000/denozord/EvoBGP/commit/bae2d68))
* feat: add debug logging and optimize database queries in Postgres repository ([6d0e214](http://192.168.100.56:3000/denozord/EvoBGP/commit/6d0e214))
* feat: add diagnostic log endpoint for revisions and implement download functionality ([2260ecf](http://192.168.100.56:3000/denozord/EvoBGP/commit/2260ecf))
* feat: add enabled state to BGP peer types and UI. Implement peer enabling toggle in the network page ([80965ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/80965ad))
* feat: add last refreshed timestamp to modules and CDN sources ([1e13ef9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e13ef9))
* feat: add live BGP protocol state tracking to peer listing ([8a50ba4](http://192.168.100.56:3000/denozord/EvoBGP/commit/8a50ba4))
* feat: add optional name field to BGP peer creation and update UI ([1a61ae2](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a61ae2))
* feat: add optional name field to DoH profile schema and types ([6bee5ad](http://192.168.100.56:3000/denozord/EvoBGP/commit/6bee5ad))
* feat: add smart aggregation for IPv4 CIDR prefixes in refresh pipeline. Implement functionality to a ([0428b83](http://192.168.100.56:3000/denozord/EvoBGP/commit/0428b83))
* feat: add strict bind configuration to BGP templates. Enhance BGP template rendering by including 's ([07f2ce5](http://192.168.100.56:3000/denozord/EvoBGP/commit/07f2ce5))
* feat: add tenant refresh functionality to HTTP API and job processing ([7a2b015](http://192.168.100.56:3000/denozord/EvoBGP/commit/7a2b015))
* feat: auto-refresh non-IP module updates and add full project manual ([1e77e1a](http://192.168.100.56:3000/denozord/EvoBGP/commit/1e77e1a))
* feat: enhance API client with pagination and new data fetching functions. Implement `apiPageAll` for ([9f0b128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f0b128))
* feat: enhance BGP template and peer configuration handling. Introduce policies_json parameter for lo ([be8e7b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/be8e7b5))
* feat: enhance CDN source management with last refreshed timestamp ([4776434](http://192.168.100.56:3000/denozord/EvoBGP/commit/4776434))
* feat: enhance community attribute handling in BIRD format. Add validation for community route bodies ([b541794](http://192.168.100.56:3000/denozord/EvoBGP/commit/b541794))
* feat: enhance community label handling and improve UI localization across operations pages. Introduc ([53fd527](http://192.168.100.56:3000/denozord/EvoBGP/commit/53fd527))
* feat: enhance deployment configuration for secure Web UI with Traefik integration ([4acd685](http://192.168.100.56:3000/denozord/EvoBGP/commit/4acd685))
* feat: enhance DoH profile management and resolver policy in modules ([34ecc5c](http://192.168.100.56:3000/denozord/EvoBGP/commit/34ecc5c))
* feat: enhance EvoBGP with new command-line options for the evobgp-agent, including a watch command f ([6a55f72](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a55f72))
* feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and ([bf52b21](http://192.168.100.56:3000/denozord/EvoBGP/commit/bf52b21))
* feat: enhance job detail handling in UI with caching and loading states. Introduce SvelteMap for job ([05bc5c4](http://192.168.100.56:3000/denozord/EvoBGP/commit/05bc5c4))
* feat: enhance job detail UI with improved overflow handling and layout adjustments. Update card comp ([67ce135](http://192.168.100.56:3000/denozord/EvoBGP/commit/67ce135))
* feat: enhance job filtering and display functionality in operations page. Introduce job search and f ([e1c4cbc](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1c4cbc))
* feat: enhance job processing with detailed logging and UI improvements. Introduce revision log entri ([c48cc4e](http://192.168.100.56:3000/denozord/EvoBGP/commit/c48cc4e))
* feat: enhance monitoring page with detailed status tracking and error handling ([458a885](http://192.168.100.56:3000/denozord/EvoBGP/commit/458a885))
* feat: enhance observability with prefix aggregation metrics ([07b97bd](http://192.168.100.56:3000/denozord/EvoBGP/commit/07b97bd))
* feat: enhance peer neighbor handling by introducing NormalizePeerNeighborString function. Update Cre ([80f3a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/80f3a8d))
* feat: enhance RefreshModule to aggregate prefixes from all enabled tenant modules. Update documentat ([431c6a9](http://192.168.100.56:3000/denozord/EvoBGP/commit/431c6a9))
* feat: enhance RefreshModule to support legacy ASN resolution and improve prefix handling. Introduce ([e1aaa18](http://192.168.100.56:3000/denozord/EvoBGP/commit/e1aaa18))
* feat: enhance settings page layout and section management ([d91667f](http://192.168.100.56:3000/denozord/EvoBGP/commit/d91667f))
* feat: enhance UI components with improved styling and layout adjustments. Update button sizes, text ([6a72842](http://192.168.100.56:3000/denozord/EvoBGP/commit/6a72842))
* feat: enhance worker functionality by adding job queuing for deploy_apply. Introduce a Registry fiel ([42d956d](http://192.168.100.56:3000/denozord/EvoBGP/commit/42d956d))
* feat: implement AS holder name resolution and enhance ASEntry structure. Add ASHolderName function t ([c958d5a](http://192.168.100.56:3000/denozord/EvoBGP/commit/c958d5a))
* feat: implement bulk selection and deletion for modules and entries. Add functionality for selecting ([e816d17](http://192.168.100.56:3000/denozord/EvoBGP/commit/e816d17))
* feat: implement CDN source preview functionality and enhance data handling. Add a new endpoint for p ([e20c9f3](http://192.168.100.56:3000/denozord/EvoBGP/commit/e20c9f3))
* feat: implement countAll function for API data retrieval ([40c8a8d](http://192.168.100.56:3000/denozord/EvoBGP/commit/40c8a8d))
* feat: implement domain resolution via DoH in refresh pipeline. Enhance the collectModulePrefixRows f ([678bd27](http://192.168.100.56:3000/denozord/EvoBGP/commit/678bd27))
* feat: implement effective peer enabling logic during peer creation. Update CreatePeer methods in Pos ([3e85431](http://192.168.100.56:3000/denozord/EvoBGP/commit/3e85431))
* feat: implement module refresh job management with concurrency control. Add CountOtherActiveModuleRe ([149fb0a](http://192.168.100.56:3000/denozord/EvoBGP/commit/149fb0a))
* feat: implement peer reconciliation job for BGP peers ([3b17228](http://192.168.100.56:3000/denozord/EvoBGP/commit/3b17228))
* feat: implement revision retention settings and pruning logic ([4f50350](http://192.168.100.56:3000/denozord/EvoBGP/commit/4f50350))
* feat: implement theme selection and automatic theme application based on user preference. Update App ([b70a643](http://192.168.100.56:3000/denozord/EvoBGP/commit/b70a643))
* feat: implement versioning and release management in the project ([bb334b1](http://192.168.100.56:3000/denozord/EvoBGP/commit/bb334b1))
* feat: improve BGP protocol summary handling by introducing isBGPProtocolSummaryRow function. Update ([399622c](http://192.168.100.56:3000/denozord/EvoBGP/commit/399622c))
* feat: improve job detail UI with enhanced spacing and layout adjustments. Update table cell classes ([97908b5](http://192.168.100.56:3000/denozord/EvoBGP/commit/97908b5))
* feat: improve peer speaker assignment UX in network settings ([f877ec8](http://192.168.100.56:3000/denozord/EvoBGP/commit/f877ec8))
* feat: improve UI components with enhanced overflow handling and layout adjustments. Update card and ([0ca5542](http://192.168.100.56:3000/denozord/EvoBGP/commit/0ca5542))
* feat: improve UI layout and responsiveness in module and operations pages. Refactor component struct ([233f29b](http://192.168.100.56:3000/denozord/EvoBGP/commit/233f29b))
* feat: redesign settings page with structured controls ([2da6d49](http://192.168.100.56:3000/denozord/EvoBGP/commit/2da6d49))
* feat: refactor materialization logic to group static routes by community in the refresh pipeline. In ([1c5a9c9](http://192.168.100.56:3000/denozord/EvoBGP/commit/1c5a9c9))
* feat: refactor operations page to enhance UI and component structure. Remove unused card components ([88226f6](http://192.168.100.56:3000/denozord/EvoBGP/commit/88226f6))
* feat: refine BGP template handling and documentation updates. Remove local IP requirements in BGP te ([6266a39](http://192.168.100.56:3000/denozord/EvoBGP/commit/6266a39))
* feat: refine job detail UI with improved layout and scrolling capabilities. Enhance spacing and over ([57c09ac](http://192.168.100.56:3000/denozord/EvoBGP/commit/57c09ac))
* feat: update BGP community structure and related documentation. Refactor BGP community handling by r ([a2cbfac](http://192.168.100.56:3000/denozord/EvoBGP/commit/a2cbfac))
* feat: update BGP template configuration and OpenAPI documentation. Change import settings from 'all' ([133ea93](http://192.168.100.56:3000/denozord/EvoBGP/commit/133ea93))
* feat: update BIRD installation process to build from source. Modify CI workflow to ensure BIRD 2.14 ([e2002ba](http://192.168.100.56:3000/denozord/EvoBGP/commit/e2002ba))
* feat: update CI configuration and Dockerfiles for improved build efficiency ([80229b3](http://192.168.100.56:3000/denozord/EvoBGP/commit/80229b3))
* feat: update collectModulePrefixRows to support prior snapshots and enhance CDN/domain prefix collec ([ee364c8](http://192.168.100.56:3000/denozord/EvoBGP/commit/ee364c8))
* feat: update Go module dependencies and enhance DNS resolution in refresh pipeline. Upgrade Go versi ([655d750](http://192.168.100.56:3000/denozord/EvoBGP/commit/655d750))
* feat: update OpenAPI documentation and internal logic for BIRD settings. Enhance the description of ([62bf987](http://192.168.100.56:3000/denozord/EvoBGP/commit/62bf987))
* feat: update RevisionDiff type and enhance OperationsDiffTab component for better diff handling. Int ([4aaaa30](http://192.168.100.56:3000/denozord/EvoBGP/commit/4aaaa30))
* feat: update Traefik deployment configuration for Let's Encrypt and logging ([39ba976](http://192.168.100.56:3000/denozord/EvoBGP/commit/39ba976))
* docs: add router-lists-ui integration documentation to README ([b7a8aab](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7a8aab))
* docs: add Web UI section to EvoBGP architecture plan, detailing Svelte and shadcn-svelte integration ([272542b](http://192.168.100.56:3000/denozord/EvoBGP/commit/272542b))
* docs: enhance EvoBGP architecture plan with new risk mitigation strategies, testing matrix for BIRD2 ([3992d01](http://192.168.100.56:3000/denozord/EvoBGP/commit/3992d01))
* docs: enhance README and CI workflow documentation for Docker image building and publishing process, ([9eb3e33](http://192.168.100.56:3000/denozord/EvoBGP/commit/9eb3e33))
* docs: enhance README and CI workflow to clarify Docker image build conditions and Gitea Container Re ([c6687b2](http://192.168.100.56:3000/denozord/EvoBGP/commit/c6687b2))
* docs: openapi.html со встроенным Redoc; инструкция для Gitea и скрипт сборки ([a754e1d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a754e1d))
* docs: update AGENTS and README to include guidelines for Conventional Commits ([4c23232](http://192.168.100.56:3000/denozord/EvoBGP/commit/4c23232))
* docs: update AGENTS and README with engineering rules and guidelines ([aff27e8](http://192.168.100.56:3000/denozord/EvoBGP/commit/aff27e8))
* docs: update architecture and API documentation to clarify job processing, in-memory registry usage, ([acc0608](http://192.168.100.56:3000/denozord/EvoBGP/commit/acc0608))
* docs: update EvoBGP architecture plan with detailed REST API section, including general agreements, ([1a86d6c](http://192.168.100.56:3000/denozord/EvoBGP/commit/1a86d6c))
* docs: update EvoBGP architecture plan with new repository structure, refined sections, and detailed ([4480d64](http://192.168.100.56:3000/denozord/EvoBGP/commit/4480d64))
* docs: update README and CI workflow to clarify Docker workspace mounting for bird2 job ([6be46db](http://192.168.100.56:3000/denozord/EvoBGP/commit/6be46db))
* docs: update README and CI workflow to clarify Gitea Registry login process and improve Docker image ([f1b33b9](http://192.168.100.56:3000/denozord/EvoBGP/commit/f1b33b9))
* docs: update README and Docker Compose configurations to clarify usage of pre-built images from the ([7fac79c](http://192.168.100.56:3000/denozord/EvoBGP/commit/7fac79c))
* docs: update README to include information about the EvoBGP web interface, linking to quickstart and ([5d21f01](http://192.168.100.56:3000/denozord/EvoBGP/commit/5d21f01))
* docs: статическая Redoc-страница OpenAPI (для просмотра в Gitea без встроенного рендера) ([5c9d2e2](http://192.168.100.56:3000/denozord/EvoBGP/commit/5c9d2e2))
* docs: убрать типографское тире и невидимый Unicode из текстов API ([fbe9cd3](http://192.168.100.56:3000/denozord/EvoBGP/commit/fbe9cd3))
* refactor: adjust BootstrapWorkers function call in server.go to change the order of returned values, ([032fa99](http://192.168.100.56:3000/denozord/EvoBGP/commit/032fa99))
* refactor: consolidate module interval display logic and improve edit form handling ([1f85654](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f85654))
* refactor: enhance bird metrics polling with context support ([f6b94a4](http://192.168.100.56:3000/denozord/EvoBGP/commit/f6b94a4))
* refactor: enhance CI workflow for granular change detection by module and improve idempotency key ge ([54eaad3](http://192.168.100.56:3000/denozord/EvoBGP/commit/54eaad3))
* refactor: enhance job status checks in worker tests ([6107c3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/6107c3e))
* refactor: enhance JSON struct tags for various data models in backend and memory store. Update field ([4a860a0](http://192.168.100.56:3000/denozord/EvoBGP/commit/4a860a0))
* refactor: enhance module display and input handling in module pages ([25374a6](http://192.168.100.56:3000/denozord/EvoBGP/commit/25374a6))
* refactor: enhance module interval label logic and add reset buttons in edit forms ([b99fc17](http://192.168.100.56:3000/denozord/EvoBGP/commit/b99fc17))
* refactor: improve code consistency and formatting across components ([c263fd5](http://192.168.100.56:3000/denozord/EvoBGP/commit/c263fd5))
* refactor: improve handling of deployable fragments in ApplyRevision and buildPreviewFragments functi ([6932271](http://192.168.100.56:3000/denozord/EvoBGP/commit/6932271))
* refactor: improve module save functionality and nullable value handling ([05ec176](http://192.168.100.56:3000/denozord/EvoBGP/commit/05ec176))
* refactor: improve PATCH handling for module updates with nullable field support ([e700f90](http://192.168.100.56:3000/denozord/EvoBGP/commit/e700f90))
* refactor: optimize job listing and counting logic for concurrency safety. Separate tenant filtering ([d83efe2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d83efe2))
* refactor: phase 2 structural alignment — reports, importer, nodecli, pagination ([250cdea](http://192.168.100.56:3000/denozord/EvoBGP/commit/250cdea))
* refactor: remove strict bind configuration from BGP templates and update related logic. Eliminate 's ([482eeb1](http://192.168.100.56:3000/denozord/EvoBGP/commit/482eeb1))
* refactor: streamline Docker build process in CI configuration and documentation ([6025f38](http://192.168.100.56:3000/denozord/EvoBGP/commit/6025f38))
* refactor: streamline edit form handling for community and DoH profile selections ([1765ab8](http://192.168.100.56:3000/denozord/EvoBGP/commit/1765ab8))
* refactor: streamline module refresh process and enhance revision handling ([10e2415](http://192.168.100.56:3000/denozord/EvoBGP/commit/10e2415))
* refactor: switch BIRD installation from source to Ubuntu package. Update CI workflow and Dockerfiles ([0b3ceaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/0b3ceaa))
* refactor: update AS entry handling in API and database to support ASN-only entries. Remove prefix fi ([1db6b5b](http://192.168.100.56:3000/denozord/EvoBGP/commit/1db6b5b))
* refactor: update Docker build context and paths in CI configuration and documentation ([7d52a4a](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d52a4a))
* refactor: update Docker build variable syntax in CI configuration and documentation ([27e01d0](http://192.168.100.56:3000/denozord/EvoBGP/commit/27e01d0))
* refactor: update job kind display logic in schedule page ([dc1f4a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc1f4a5))
* refactor: update ListModules and GetModule methods in Postgres repository to include cron expression ([73d0003](http://192.168.100.56:3000/denozord/EvoBGP/commit/73d0003))
* refactor: update Switch component styles and structure for improved accessibility and responsiveness ([d4282c2](http://192.168.100.56:3000/denozord/EvoBGP/commit/d4282c2))
* refactor: update worker processes in EvoBGP to accept dependencies for shared store and job registry ([b7968db](http://192.168.100.56:3000/denozord/EvoBGP/commit/b7968db))
* ci: add conditional job for OpenAPI linting based on file changes; update README with workflow detai ([3adfc49](http://192.168.100.56:3000/denozord/EvoBGP/commit/3adfc49))
* ci: phase 4 automation — web job, lint-httpapi, golangci ([64516c3](http://192.168.100.56:3000/denozord/EvoBGP/commit/64516c3))
* ci: update README and CI workflow to improve bird2 job installation process and clarify Docker usage ([eddc6b8](http://192.168.100.56:3000/denozord/EvoBGP/commit/eddc6b8))
* chore: phase 3 code quality — pipeline prefixes, godoc baseline ([ea65451](http://192.168.100.56:3000/denozord/EvoBGP/commit/ea65451))
* chore: update CI configuration to disable filesystem entitlement for Docker builds ([9ab7de6](http://192.168.100.56:3000/denozord/EvoBGP/commit/9ab7de6))
* chore: update package dependencies and enhance AppShell component with new navigation items and tool ([7d85fef](http://192.168.100.56:3000/denozord/EvoBGP/commit/7d85fef))
* chore: upgrade Go version to 1.24 in Dockerfiles for evobgp-agent and gobinary. This change ensures ([4ba5b03](http://192.168.100.56:3000/denozord/EvoBGP/commit/4ba5b03))
* fix: adjust Postgres resource limits and healthcheck parameters. Increase memory limit to 256M and m ([20e674c](http://192.168.100.56:3000/denozord/EvoBGP/commit/20e674c))
* fix: improve input handling and validation for settings fields ([1386b63](http://192.168.100.56:3000/denozord/EvoBGP/commit/1386b63))
* fix: improve sidebar and navigation layout in AppShell component ([9f02128](http://192.168.100.56:3000/denozord/EvoBGP/commit/9f02128))
* fix: update BGP configuration in test scenarios to use new IPv6 addresses ([9fd9745](http://192.168.100.56:3000/denozord/EvoBGP/commit/9fd9745))
* fix: update bird build script and Dockerfile to include libncurses dependency ([0cb5a7d](http://192.168.100.56:3000/denozord/EvoBGP/commit/0cb5a7d))
* fix: update bird build script and Dockerfile to include readline dependency ([35ed79b](http://192.168.100.56:3000/denozord/EvoBGP/commit/35ed79b))
* fix: update bird build script and Dockerfile to remove readline dependency ([fefadac](http://192.168.100.56:3000/denozord/EvoBGP/commit/fefadac))
* fix: update deployment configuration for Traefik and secure Web UI ([00bd037](http://192.168.100.56:3000/denozord/EvoBGP/commit/00bd037))
* fix: update Docker build commands in CI configuration and documentation ([b5061a5](http://192.168.100.56:3000/denozord/EvoBGP/commit/b5061a5))
* fix(httpapi): phase 1 compliance — ERR-01, SEC-04, CDN client, scheduler docs ([1dd7135](http://192.168.100.56:3000/denozord/EvoBGP/commit/1dd7135))
* test: add CDN prefetch tests for 304 responses and snapshot handling ([a536a2d](http://192.168.100.56:3000/denozord/EvoBGP/commit/a536a2d))
* test: add validation for 'strict bind on' in BGP rendering tests. Enhance BGP test cases to ensure ' ([dc5e20e](http://192.168.100.56:3000/denozord/EvoBGP/commit/dc5e20e))
* docker: refactor cache handling in Docker bake configuration ([5e59a1c](http://192.168.100.56:3000/denozord/EvoBGP/commit/5e59a1c))
* docker: simplify web image targets in Docker bake configuration ([765a805](http://192.168.100.56:3000/denozord/EvoBGP/commit/765a805))
* docker: update web image build process and README ([92f6de8](http://192.168.100.56:3000/denozord/EvoBGP/commit/92f6de8))
* web(ui): app layout — sidebar, mobile sheet, topbar, PageHeader ([2d78c81](http://192.168.100.56:3000/denozord/EvoBGP/commit/2d78c81))
* web(ui): AppDataTable pattern and modules pilot ([b992301](http://192.168.100.56:3000/denozord/EvoBGP/commit/b992301))
* web(ui): design tokens — spacing and component scale ([ac635e9](http://192.168.100.56:3000/denozord/EvoBGP/commit/ac635e9))
* web(ui): feedback — confirm dialog, skeletons, README ([87309cf](http://192.168.100.56:3000/denozord/EvoBGP/commit/87309cf))
* web(ui): form patterns — FormField, formsnap, directories pilot ([77a2cfd](http://192.168.100.56:3000/denozord/EvoBGP/commit/77a2cfd))
* web(ui): foundation — ui/core layout, shadcn primitives, notify ([00a2a44](http://192.168.100.56:3000/denozord/EvoBGP/commit/00a2a44))
* web(ui): migrate dashboard, schedule, monitoring to ui patterns ([57dcf30](http://192.168.100.56:3000/denozord/EvoBGP/commit/57dcf30))
* web(ui): migrate network to ui patterns ([3a3056b](http://192.168.100.56:3000/denozord/EvoBGP/commit/3a3056b))
* web(ui): migrate operations and module detail to ui patterns ([1f9930d](http://192.168.100.56:3000/denozord/EvoBGP/commit/1f9930d))
* web(ui): migrate settings to ui patterns ([9808aca](http://192.168.100.56:3000/denozord/EvoBGP/commit/9808aca))
* Enhance EvoBGP architecture plan by refining the overview, updating todos with detailed descriptions ([30efdbf](http://192.168.100.56:3000/denozord/EvoBGP/commit/30efdbf))
* init ([cbf3aaa](http://192.168.100.56:3000/denozord/EvoBGP/commit/cbf3aaa))
* Refine EvoBGP architecture plan with updated database schema details, improved descriptions for comp ([431ef3e](http://192.168.100.56:3000/denozord/EvoBGP/commit/431ef3e))
* Update EvoBGP architecture plan with new overview and todos. Refined descriptions for control-plane ([31ff5bb](http://192.168.100.56:3000/denozord/EvoBGP/commit/31ff5bb))
+8
View File
@@ -44,4 +44,12 @@ docker compose --profile reference up -d
- **Go:** модуль `evobgp`, точки входа в `cmd/`.
- **Веб:** SvelteKit в каталоге `web/` (см. [web/README.md](web/README.md)).
## Версионирование и релизы
Версии определяются автоматически из Conventional Commits при merge в `main` (semantic-release на Gitea Actions). Первая версия — **1.0.0**; ручной bump не нужен.
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
- API: `GET /version` и `GET /v1/version` (поле `version`)
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
Лицензия и условия использования — по политике владельца репозитория.
+2 -1
View File
@@ -19,6 +19,7 @@ import (
"evobgp/internal/platform"
"evobgp/internal/render"
"evobgp/internal/scheduler"
"evobgp/internal/version"
)
// microVPS entrypoint: один процесс — HTTP API и фоновые воркеры scheduler, ingest, render, deploy (общий store и jobs.Registry).
@@ -37,7 +38,7 @@ func main() {
log.Fatal(err)
}
defer srv.Close()
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
observability.SetBuildInfo(version.Version, version.GitSHA)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
+2 -1
View File
@@ -15,6 +15,7 @@ import (
"evobgp/internal/httpapi"
"evobgp/internal/observability"
"evobgp/internal/platform"
"evobgp/internal/version"
)
func main() {
@@ -34,7 +35,7 @@ func main() {
}
defer srv.Close()
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
observability.SetBuildInfo(version.Version, version.GitSHA)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
+1 -1
View File
@@ -96,7 +96,7 @@ services:
<<: *env-ref
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
# Local reference only: allows Bearer dev for scheduler HTTP client (EVOBGP_SCHEDULER_BEARER).
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_BIRDC_INTERVAL: 30s
+1
View File
@@ -135,6 +135,7 @@ services:
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
# DEV ONLY — не для production (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
+7 -1
View File
@@ -2,7 +2,11 @@
## CI (Gitea Actions)
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), а не матрица из восьми отдельных `docker build`.
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`.
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза.
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
| Было | Стало |
|------|--------|
@@ -31,6 +35,8 @@ cd deploy/docker
export REGISTRY=git.shts.su/<owner>
export IMAGE_TAG=latest
export SHORT_SHA=$(git rev-parse --short HEAD)
export VERSION=dev
export BUILD_TIME=
sh write-bake-override.sh
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl go-images
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl -f docker-bake.override.hcl web-images
+27 -8
View File
@@ -19,6 +19,14 @@ variable "SHA_FULL" {
default = ""
}
variable "VERSION" {
default = "dev"
}
variable "BUILD_TIME" {
default = ""
}
variable "CACHE_REF_GO" {
default = ""
}
@@ -87,6 +95,11 @@ target "go-build-all" {
dockerfile = "deploy/docker/gobinary/Dockerfile"
target = "build-all"
platforms = ["linux/amd64"]
args = {
VERSION = VERSION
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
BUILD_TIME = BUILD_TIME
}
contexts = {
deps = "target:go-deps"
}
@@ -127,14 +140,20 @@ target "_go-runtime-birdc" {
function "image-tags" {
params = [name]
result = notequal("", SHA_FULL) ? [
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
"${REGISTRY}/${name}:sha-${SHA_FULL}",
] : [
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
]
result = concat(
notequal("", SHA_FULL) ? [
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
"${REGISTRY}/${name}:sha-${SHA_FULL}",
] : [
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
],
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
"${REGISTRY}/${name}:v${VERSION}",
"${REGISTRY}/${name}:${VERSION}",
] : []
)
}
target "evobgp-api" {
+7 -2
View File
@@ -5,8 +5,13 @@ server {
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
resolver 127.0.0.11 valid=10s ipv6=off;
set $evobgp_upstream evobgp-api;
location /v1/ {
proxy_pass http://evobgp-api:8080/v1/;
proxy_pass http://$evobgp_upstream:8080/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -15,7 +20,7 @@ server {
}
location = /metrics {
proxy_pass http://evobgp-api:8080/metrics;
proxy_pass http://$evobgp_upstream:8080/metrics;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
+18 -2
View File
@@ -10,22 +10,38 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
FROM deps AS build-all
COPY . .
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
set -eux; \
mkdir -p /out; \
for d in cmd/*/; do \
name="$(basename "$d")"; \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "/out/${name}" "./cmd/${name}"; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w \
-X evobgp/internal/version.Version=${VERSION} \
-X evobgp/internal/version.GitSHA=${GIT_SHA} \
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
-o "/out/${name}" "./cmd/${name}"; \
done
# Один бинарь (локальная сборка); в CI — build-all + runtime.
FROM deps AS build
COPY . .
ARG BIN=evobgp-api
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp "./cmd/${BIN}"
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w \
-X evobgp/internal/version.Version=${VERSION} \
-X evobgp/internal/version.GitSHA=${GIT_SHA} \
-X evobgp/internal/version.BuildTime=${BUILD_TIME}" \
-o /out/evobgp "./cmd/${BIN}"
FROM public.ecr.aws/docker/library/debian:bookworm-slim AS birdc
ARG BIRD_VERSION=2.14
+8
View File
@@ -6,6 +6,8 @@ REGISTRY="${REGISTRY:?REGISTRY required}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
SHORT_SHA="${SHORT_SHA:-dev}"
SHA_FULL="${SHA_FULL:-}"
VERSION="${VERSION:-dev}"
BUILD_TIME="${BUILD_TIME:-}"
CACHE_REF_GO="${CACHE_REF_GO:-}"
CACHE_REF_WEB="${CACHE_REF_WEB:-}"
cat >"$OUT" <<EOF
@@ -22,6 +24,12 @@ variable "SHORT_SHA" {
variable "SHA_FULL" {
default = "${SHA_FULL}"
}
variable "VERSION" {
default = "${VERSION}"
}
variable "BUILD_TIME" {
default = "${BUILD_TIME}"
}
variable "CACHE_REF_GO" {
default = "${CACHE_REF_GO}"
}
+16
View File
@@ -20,6 +20,7 @@
| [api.md](api.md) | REST: префикс `/v1`, публичные маршруты, ссылки на OpenAPI |
| [router-lists-ui-integration.md](router-lists-ui-integration.md) | Интеграция `router-lists-ui` с EvoBGP API (`DOMAINS/IP_RANGES/AS_PREFIXES/communities`) |
| [access.md](access.md) | Выдача доступа: API-ключи, роли, нода, CORS |
| [releasing.md](releasing.md) | Автоматические релизы, Conventional Commits, CI |
| [openapi.yaml](openapi.yaml) | Источник правды по контракту API |
| [OPENAPI-GITEA.md](OPENAPI-GITEA.md) | Как открыть HTML-документацию API (в т.ч. из Gitea) |
| [evobgp-api-sketches.md](evobgp-api-sketches.md) | Ранний черновик идей API (контекст, не замена OpenAPI) |
@@ -31,7 +32,22 @@
| [../.cursor/rules/engineering.mdc](../.cursor/rules/engineering.mdc) | Go, API, migrations, security, enforcement |
| [../.cursor/rules/web-shadcn.mdc](../.cursor/rules/web-shadcn.mdc) | SvelteKit, shadcn-svelte |
| [../.cursor/rules/networking-bird.mdc](../.cursor/rules/networking-bird.mdc) | BIRD2, BGP policy, IP/CIDR |
| [../.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) | Conventional Commits (заголовок EN, тело RU) |
### Сообщения коммитов (Cursor)
После `git add`:
| Способ | Skill + rule + `staged-context.ps1` |
|--------|-------------------------------------|
| Agent → **`/commit-message`** или «сгенерируй коммит по staged» | **Да** |
| Кнопка **✨ Generate commit message** в Source Control | **Нет** (только diff + история; [ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)) |
Рекомендуемый workflow: Agent Chat → **`/commit-message`** ([команда](../.cursor/commands/commit-message.md), [скилл](../.cursor/skills/commit-message/SKILL.md), [правило](../.cursor/rules/conventional-commits.mdc)). Агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1` (заголовок EN, тело RU, auto-split).
Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
## Репозиторий и CI
- [../.gitea/README.md](../.gitea/README.md) — Gitea Actions, runner, сборка образов и push в Container Registry.
- [releasing.md](releasing.md) — semantic-release, commit conventions, теги образов.
+6 -1
View File
@@ -37,7 +37,12 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные.
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные. В reference Compose (`deploy/compose/docker-compose.yaml`) флаг включён только для локальной разработки.
### Синхронные «тяжёлые» GET (control plane)
- `POST /v1/modules/{module_id}/cdn-sources/preview` — загрузка CDN в том же HTTP-запросе (лимит тела ~8 MiB, см. OpenAPI).
- `GET /v1/bird/status` (если маршрут включён в деплое) — опрос локального `birdc`, таймаут сервера ~12 с.
### Детерминированный ключ подписи бандлов (тесты)
+36 -4
View File
@@ -1,10 +1,12 @@
openapi: 3.1.0
info:
title: EvoBGP Control Plane API
version: 0.1.0
version: 1.0.0
description: |
REST API управления префиксами, модулями ingest, ревизиями конфигурации BIRD и задачами (async jobs).
**Актуальная semver-сборка:** `GET /version` или `GET /v1/version` (поле `version`; совпадает с git-тегом `vX.Y.Z`).
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
@@ -65,7 +67,9 @@ components:
required: false
schema:
$ref: "#/components/schemas/ResourceId"
description: Явный tenant (только супер-роли). Без заголовка tenant определяется по ключу.
description: >
Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу.
**Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md).
IdempotencyKey:
name: Idempotency-Key
in: header
@@ -758,8 +762,14 @@ components:
VersionInfo:
type: object
properties:
version:
type: string
description: Semver сборки (git tag без префикса v).
example: "1.2.3"
api_version:
type: string
deprecated: true
description: Alias поля `version` (сохранён для обратной совместимости).
git_sha:
type: string
build_time:
@@ -967,11 +977,31 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/version:
get:
tags: [System]
summary: Версия сборки (корневой путь)
description: |
Аналог `GET /v1/version`. Публичный маршрут без аутентификации.
Semver в поле `version` задаётся при сборке Docker-образов из git-тега.
operationId: getVersionRoot
responses:
"200":
description: Метаданные сборки.
content:
application/json:
schema:
$ref: "#/components/schemas/VersionInfo"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/version:
get:
tags: [System]
summary: Версия сборки
description: Версия API и control-plane (`git_sha`, `build_time` и др.).
description: |
Semver control-plane и метаданные сборки (`git_sha`, `build_time`).
Дублирует `GET /version`.
operationId: getVersion
responses:
"200":
@@ -1783,7 +1813,9 @@ paths:
tags: [Modules]
summary: Предпросмотр префиксов из CDN-источника
description: >
Загружает URL, парсит как plaintext или json и возвращает список извлечённых префиксов (до 100 записей).
Синхронный запрос: conditional GET к URL (до 8 MiB тела ответа), парсинг plaintext или JSON,
возврат до 100 префиксов в `items` (полный счётчик в `total`). Выполняется в HTTP worker;
при таймауте клиента используйте короткий URL или меньший payload.
operationId: previewCdnSource
requestBody:
required: true
+76
View File
@@ -0,0 +1,76 @@
# Релизы и версионирование EvoBGP
EvoBGP использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shts.su`). Ручное повышение версии в коде не требуется.
## Как определяется версия
| Тип коммита | Bump |
|-------------|------|
| `feat` | minor (1.0.0 → 1.1.0) |
| `fix`, `perf`, `ci` | patch (1.5.1 → 1.5.2) |
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
| `docs`, `chore`, `test`, `refactor` | без релиза |
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
**Как не перепутать `feat` и `fix`:** см. раздел «Выбор type: semver, а не «красивые слова»» в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc). Кратко: `feat` — новая возможность (minor); `fix` — починка ожидаемого поведения (patch); follow-up баги после недавнего `feat` — всегда `fix`, даже если diff большой.
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
## CI-пайплайн (один push в main)
```text
push/merge в main
→ CI: openapi, web, go, bird2 (параллельно)
→ job release (в том же workflow, после quality gates):
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
→ Gitea Release + CHANGELOG.md как attachment
→ docker buildx bake с VERSION=X.Y.Z
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
```
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются.
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
## Секреты Gitea
Один PAT — **`ACTIONS_PAT`** (Settings → Actions → Secrets).
| Право PAT | Зачем |
|-----------|--------|
| push tags | git tag `vX.Y.Z` на commit merge |
| releases | Gitea Release + notes |
| packages (Container Registry) | push образов |
Fallback: **`gitea.token`** (нужны права на releases и packages).
## Источник правды для версии в runtime
Semver из git-тега пробрасывается в Go-бинарники через `-ldflags` при сборке Docker (`deploy/docker/gobinary/Dockerfile`). Пакет [`internal/version`](../internal/version/version.go):
- локально (`go run`) — `version: "dev"`
- в образе после релиза — совпадает с тегом (например `1.2.3`)
API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build_time`.
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
## CHANGELOG
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
## Проверка после релиза
1. Один run workflow **CI** на push в main: job **release** зелёный.
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
4. `curl http://localhost:8080/version` → `"version":"X.Y.Z"`.
## Первый релиз (bootstrap)
Merge в `main` с `feat(release): ...` → **v1.0.0** в том же CI run.
+4 -4
View File
@@ -42,7 +42,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
if err != nil {
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, err
@@ -52,8 +52,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
}
var wrap struct {
Status string `json:"status"`
Data struct {
Status string `json:"status"`
Data struct {
Prefixes []struct {
Prefix string `json:"prefix"`
} `json:"prefixes"`
@@ -104,7 +104,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
+4 -4
View File
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
type BGPPeerFromTemplateOptions struct {
ProtocolName string
TemplateName string
NeighborIP string
NeighborASN uint32
ProtocolName string
TemplateName string
NeighborIP string
NeighborASN uint32
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
OverrideLocalIP string
OverrideLocalASN uint32
+6 -6
View File
@@ -17,12 +17,12 @@ import (
// Manifest describes bundle contents for evobgp-node verification.
type Manifest struct {
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
}
// FileEntry is one file inside the bundle archive.
+1 -1
View File
@@ -27,7 +27,7 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
if err != nil {
return nil, err
}
defer gr.Close()
defer func() { _ = gr.Close() }()
var manifestRaw []byte
var sig []byte
+14
View File
@@ -3,6 +3,8 @@ package httpapi
import (
"net/http"
"strconv"
"evobgp/internal/store"
)
func parseListLimit(r *http.Request) int {
@@ -22,3 +24,15 @@ func strPtrOrNull(s string) any {
}
return s
}
// writePaginatedListJSON returns a cursor/limit page as OpenAPI list envelopes (items, next_cursor, has_more).
func writePaginatedListJSON[T any](w http.ResponseWriter, r *http.Request, all []T, toItem func(T) map[string]any) {
page, next, more := store.PaginateOffset(all, r.URL.Query().Get("cursor"), parseListLimit(r))
items := make([]map[string]any, 0, len(page))
for _, x := range page {
items = append(items, toItem(x))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
+9 -5
View File
@@ -9,15 +9,19 @@ import (
const (
internalErrorDetail = "an internal error occurred"
badGatewayDetail = "upstream request failed"
notFoundDetail = "resource not found"
invalidInputDetail = "invalid request data"
cdnExtractDetail = "could not extract prefixes from source"
csvInvalidRowDetail = "invalid row in csv file"
)
// Problem is RFC 9457 application/problem+json.
type Problem struct {
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
+18 -8
View File
@@ -21,6 +21,7 @@ import (
"evobgp/internal/pipeline"
"evobgp/internal/reports"
"evobgp/internal/store"
"evobgp/internal/version"
)
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
@@ -30,6 +31,7 @@ func (s *Server) Handler() http.Handler {
wrappedV1 := http.StripPrefix("/v1", v1)
s.mux.Handle("GET /metrics", observability.MetricsHandler())
s.mux.HandleFunc("GET /version", s.handleVersion)
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
@@ -82,12 +84,15 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
checks := map[string]string{"store": "ok", "jobs": "memory"}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if s.pgPool != nil {
if err := s.pgPool.Ping(ctx); err != nil {
if err := s.store.Ping(ctx); err != nil {
checks["store"] = "unavailable"
if s.pgPool != nil {
checks["postgres"] = "unavailable"
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
return
}
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
return
}
if s.pgPool != nil {
checks["postgres"] = "ok"
} else {
checks["store_backend"] = "memory"
@@ -96,11 +101,16 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
if sha == "" {
sha = "unknown"
ver, sha, buildTime := version.Info()
resp := map[string]string{
"version": ver,
"api_version": ver,
"git_sha": sha,
}
writeJSON(w, http.StatusOK, map[string]string{"api_version": "0.1.0", "git_sha": sha})
if buildTime != "" {
resp["build_time"] = buildTime
}
writeJSON(w, http.StatusOK, resp)
}
func moduleJSON(mod *store.Module) map[string]any {
+15 -26
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
@@ -172,12 +173,15 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
}
func writeStoreErr(w http.ResponseWriter, err error) {
if err != nil {
log.Printf("httpapi: store: %v", err)
}
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
return
}
if err == store.ErrInvalidInput {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
writeInternalError(w, "store", err)
@@ -193,11 +197,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, cdnSourceJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
writePaginatedListJSON(w, r, list, cdnSourceJSON)
}
func cdnSourceJSON(x *store.CDNSource) map[string]any {
@@ -258,7 +258,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
writeBadGateway(w, "cdn preview fetch", err)
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
@@ -271,7 +271,8 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
}
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
log.Printf("httpapi: cdn preview extract: %v", err)
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", cdnExtractDetail)
return
}
items := make([]string, 0, len(pfxs))
@@ -354,11 +355,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, asEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
writePaginatedListJSON(w, r, list, asEntryJSON)
}
func asEntryJSON(x *store.ASEntry) map[string]any {
@@ -450,11 +447,7 @@ func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, domainEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
writePaginatedListJSON(w, r, list, domainEntryJSON)
}
func domainEntryJSON(x *store.DomainEntry) map[string]any {
@@ -531,11 +524,7 @@ func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, ipRangeJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
writePaginatedListJSON(w, r, list, ipRangeJSON)
}
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
@@ -702,8 +691,8 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
return
}
if strings.Contains(err.Error(), "importer: line") {
detail := strings.TrimPrefix(err.Error(), "importer: ")
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
log.Printf("httpapi: csv import: %v", err)
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", csvInvalidRowDetail)
return
}
if strings.Contains(err.Error(), "importer: csv import/export") {
+3 -3
View File
@@ -34,7 +34,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respList.Body.Close()
defer func() { _ = respList.Body.Close() }()
if respList.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respList.Body)
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respImport.Body.Close()
defer func() { _ = respImport.Body.Close() }()
if respImport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respImport.Body)
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respExport.Body.Close()
defer func() { _ = respExport.Body.Close() }()
if respExport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respExport.Body)
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
+87
View File
@@ -0,0 +1,87 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNestedModuleListPagination(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("edkey|" + tenant + "|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
mid := modIP
for i := 0; i < 3; i++ {
body := strings.NewReader(fmt.Sprintf(`{"prefix":"10.%d.0.0/24"}`, 200+i))
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+mid+"/ip-range-entries", body)
req.Header.Set("Authorization", "Bearer edkey")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
}
}
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2", nil)
req.Header.Set("Authorization", "Bearer edkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("list status %d: %s", resp.StatusCode, b)
}
var page1 struct {
Items []map[string]any `json:"items"`
NextCursor *string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}
if err := json.NewDecoder(resp.Body).Decode(&page1); err != nil {
t.Fatal(err)
}
if len(page1.Items) != 2 {
t.Fatalf("page1 items: got %d want 2", len(page1.Items))
}
if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor == "" {
t.Fatalf("page1: has_more=%v next_cursor=%v", page1.HasMore, page1.NextCursor)
}
req2, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2&cursor="+*page1.NextCursor, nil)
req2.Header.Set("Authorization", "Bearer edkey")
resp2, err := client.Do(req2)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
var page2 struct {
Items []map[string]any `json:"items"`
HasMore bool `json:"has_more"`
}
if err := json.NewDecoder(resp2.Body).Decode(&page2); err != nil {
t.Fatal(err)
}
if len(page1.Items)+len(page2.Items) < 3 {
t.Fatalf("expected at least 3 entries across pages, got %d+%d", len(page1.Items), len(page2.Items))
}
}
+4 -4
View File
@@ -32,10 +32,10 @@ type Server struct {
type Options struct {
APIKeys string
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
DatabaseURL string
InsecureDev bool
SeedDemo bool
BundleSeedHex string
DatabaseURL string
InsecureDev bool
SeedDemo bool
BundleSeedHex string
CORSAllowedOrigins string
}
+71 -13
View File
@@ -13,6 +13,7 @@ import (
"evobgp/internal/jobs"
"evobgp/internal/signing"
"evobgp/internal/version"
)
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
@@ -50,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -75,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -96,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -117,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -131,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -161,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
}
@@ -184,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -214,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -254,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
@@ -268,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -288,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -313,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -340,7 +341,7 @@ func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
var body struct {
Status string `json:"status"`
}
@@ -371,3 +372,60 @@ evobgp_p_01234567 BGP master start 2026-04-09 Connect
t.Fatalf("expected protocol state Connect, got %q", got["evobgp_p_01234567"])
}
}
func TestVersionEndpoints(t *testing.T) {
srv, err := New(Options{
InsecureDev: true,
SeedDemo: true,
BundleSeedHex: testBundleSeed,
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
oldVer := version.Version
oldSHA := version.GitSHA
oldTime := version.BuildTime
version.Version = "1.2.3"
version.GitSHA = "abc123def456"
version.BuildTime = "2026-05-20T12:00:00Z"
t.Cleanup(func() {
version.Version = oldVer
version.GitSHA = oldSHA
version.BuildTime = oldTime
})
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
for _, path := range []string{"/version", "/v1/version"} {
t.Run(path, func(t *testing.T) {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["version"] != "1.2.3" {
t.Fatalf("version=%q want 1.2.3", body["version"])
}
if body["api_version"] != "1.2.3" {
t.Fatalf("api_version=%q want 1.2.3", body["api_version"])
}
if body["git_sha"] != "abc123def456" {
t.Fatalf("git_sha=%q", body["git_sha"])
}
if body["build_time"] != "2026-05-20T12:00:00Z" {
t.Fatalf("build_time=%q", body["build_time"])
}
})
}
}
+2 -2
View File
@@ -66,7 +66,7 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
if err != nil {
return "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
@@ -94,7 +94,7 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
+1 -1
View File
@@ -27,7 +27,7 @@ func Run(args []string) int {
// Usage prints CLI help to w.
func Usage(w interface{ Write([]byte) (int, error) }) {
fmt.Fprintf(w, `Usage:
_, _ = fmt.Fprintf(w, `Usage:
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
evobgp-node apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
+1 -1
View File
@@ -126,7 +126,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
}
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
@@ -46,4 +46,3 @@ func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
t.Fatalf("expected deterministic static preview text, got first:\n%s\nsecond:\n%s", staticV4, staticV4Second)
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func ParseCIDRLines(body string) []netip.Prefix {
return out
}
// ExtractCIDRs parses CIDRs from either plaintext lines or JSON payload.
// ExtractCIDRs parses CIDR prefixes from plaintext lines or a JSON payload (see sourceKind and prefixPath).
// For sourceKind="json", prefixPath supports dotted traversal, with [] for arrays:
// e.g. "prefixes[]", "data.items[].cidr".
func ExtractCIDRs(body, sourceKind, prefixPath string) ([]netip.Prefix, error) {
+10 -10
View File
@@ -306,7 +306,7 @@ func resolveDomainWithDOHMessage(ctx context.Context, hc *http.Client, baseURL,
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body)))
@@ -378,7 +378,7 @@ func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, hos
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body)))
@@ -725,15 +725,15 @@ func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
}
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
h.Write([]byte(strings.TrimSpace(tenantID)))
h.Write([]byte{0})
_, _ = h.Write([]byte(strings.TrimSpace(tenantID)))
_, _ = h.Write([]byte{0})
for _, l := range lines {
h.Write([]byte(l.p))
h.Write([]byte{1})
h.Write([]byte(l.c))
h.Write([]byte{1})
h.Write([]byte(l.s))
h.Write([]byte{0})
_, _ = h.Write([]byte(l.p))
_, _ = h.Write([]byte{1})
_, _ = h.Write([]byte(l.c))
_, _ = h.Write([]byte{1})
_, _ = h.Write([]byte(l.s))
_, _ = h.Write([]byte{0})
}
}
+70
View File
@@ -0,0 +1,70 @@
package repository
import (
"context"
"os"
"testing"
"evobgp/internal/db"
"evobgp/internal/store"
)
func TestPostgresImplementsBackend(t *testing.T) {
var _ store.Backend = (*Postgres)(nil)
}
func TestPostgresPingIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
if err := pg.Ping(ctx); err != nil {
t.Fatalf("ping: %v", err)
}
}
func TestPostgresModuleCRUDIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
tenant := "01TESTTENANT00000000000001"
mod, err := pg.CreateModule(tenant, &store.Module{
Name: "audit-test",
Type: "IP_RANGES",
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
got, err := pg.GetModule(tenant, mod.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "audit-test" {
t.Fatalf("name: got %q", got.Name)
}
if err := pg.SoftDeleteModule(tenant, mod.ID); err != nil {
t.Fatal(err)
}
}
+6 -1
View File
@@ -28,7 +28,7 @@ func agentDebugNDJSON3214(hypothesisID, location, message string, data map[strin
if err != nil {
return
}
defer f.Close()
defer func() { _ = f.Close() }()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
payload := map[string]any{
@@ -71,6 +71,11 @@ func (p *Postgres) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker str
return p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk
}
// Ping checks PostgreSQL connectivity.
func (p *Postgres) Ping(ctx context.Context) error {
return p.pool.Ping(ctx)
}
func (p *Postgres) MaterializedPrefixStats() (max int, sum int) {
ctx := context.Background()
// Агрегация в БД — не тащим все строки config_revision в память.
+1 -1
View File
@@ -121,7 +121,7 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
return nil
}
+7 -1
View File
@@ -1,6 +1,9 @@
package store
import "time"
import (
"context"
"time"
)
// Backend is the persistence abstraction for the control plane (memory, PostgreSQL, SQLite).
type Backend interface {
@@ -90,6 +93,9 @@ type Backend interface {
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
// Ping verifies backend connectivity (no-op for in-memory).
Ping(ctx context.Context) error
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
+14 -7
View File
@@ -1,6 +1,7 @@
package store
import (
"context"
"errors"
"fmt"
"sort"
@@ -32,13 +33,13 @@ type Memory struct {
peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
@@ -291,6 +292,12 @@ func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker strin
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
}
// Ping is a no-op for the in-memory backend.
func (m *Memory) Ping(ctx context.Context) error {
_ = ctx
return nil
}
// ListTenantIDs returns tenant ids sorted lexicographically.
func (m *Memory) ListTenantIDs() ([]string, error) {
m.mu.RLock()
+3 -3
View File
@@ -19,9 +19,9 @@ func TestEffectivePeerEnabledOnCreate(t *testing.T) {
func TestParsePeerNeighbor(t *testing.T) {
tests := []struct {
in string
want string
wantOK bool
in string
want string
wantOK bool
}{
{"192.168.0.2", "192.168.0.2", true},
{"192.168.0.2/32", "192.168.0.2", true},
+23
View File
@@ -0,0 +1,23 @@
// Package version holds build metadata injected at link time via -ldflags.
package version
// Link-time variables (defaults for local development).
var (
Version = "dev"
GitSHA = "unknown"
BuildTime = ""
)
// Info returns build metadata, substituting safe defaults for empty values.
func Info() (ver, gitSHA, buildTime string) {
ver = Version
if ver == "" {
ver = "dev"
}
gitSHA = GitSHA
if gitSHA == "" {
gitSHA = "unknown"
}
buildTime = BuildTime
return ver, gitSHA, buildTime
}
+7720
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "evobgp-release",
"private": true,
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@markwylde/semantic-release-gitea": "^2.2.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"semantic-release": "^25.0.2"
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env sh
# DEP-03: postgres and sqlite migration sets must have matching numbered pairs.
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
PG="$ROOT/migrations/postgres"
SQL="$ROOT/migrations/sqlite"
list_nums() {
dir="$1"
ls "$dir" 2>/dev/null | sed -n 's/^\([0-9]\{6\}\)_.*\.up\.sql$/\1/p' | sort -u
}
migration_only_in() {
# Prints numbers present in $1 but not in $2 (space-separated lists).
haystack="$2 "
for n in $1; do
case "$haystack" in
*" $n "*) ;;
*) echo "$n" ;;
esac
done
}
pg_nums="$(list_nums "$PG")"
sql_nums="$(list_nums "$SQL")"
if [ "$pg_nums" != "$sql_nums" ]; then
echo "check-migrations-pair: postgres and sqlite migration numbers differ" >&2
only_pg="$(migration_only_in "$pg_nums" "$sql_nums")"
only_sql="$(migration_only_in "$sql_nums" "$pg_nums")"
if [ -n "$only_pg" ]; then
echo "postgres only:" >&2
for n in $only_pg; do
echo " $n" >&2
done
fi
if [ -n "$only_sql" ]; then
echo "sqlite only:" >&2
for n in $only_sql; do
echo " $n" >&2
done
fi
exit 1
fi
for n in $pg_nums; do
pg_up=""
sql_up=""
for f in "$PG"/${n}_*.up.sql; do
if [ -f "$f" ]; then
pg_up="$f"
break
fi
done
for f in "$SQL"/${n}_*.up.sql; do
if [ -f "$f" ]; then
sql_up="$f"
break
fi
done
if [ -z "$pg_up" ] || [ -z "$sql_up" ]; then
echo "check-migrations-pair: missing .up.sql for $n" >&2
exit 1
fi
pg_base="$(basename "$pg_up" .up.sql)"
sql_base="$(basename "$sql_up" .up.sql)"
if [ "$pg_base" != "$sql_base" ]; then
echo "check-migrations-pair: name mismatch for $n: $pg_base vs $sql_base" >&2
exit 1
fi
done
echo "check-migrations-pair: ok ($PG and $SQL)"
+181
View File
@@ -0,0 +1,181 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Staged git changes grouped by EvoBGP scope for Conventional Commit generation.
.DESCRIPTION
Outputs JSON to stdout: file groups, --stat, and truncated patch per group.
Exit 1 if nothing is staged. Does not write commit messages.
.EXAMPLE
powershell -NoProfile -File scripts/commit/staged-context.ps1
powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$MaxLinesPerGroup = 120
$MaxLinesTotal = 500
function Test-GitRepository {
$null = git rev-parse --git-dir 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Error 'staged-context: not a git repository'
exit 2
}
}
function Get-ScopeForPath {
param([string]$Path)
$p = $Path -replace '\\', '/'
# Longest / most specific prefixes first
$rules = @(
@{ Prefix = 'docs/openapi.yaml'; Scope = 'openapi' }
@{ Prefix = 'redocly.yaml'; Scope = 'openapi' }
@{ Prefix = 'internal/httpapi/'; Scope = 'httpapi' }
@{ Prefix = 'internal/store/'; Scope = 'store' }
@{ Prefix = 'internal/repository/'; Scope = 'store' }
@{ Prefix = 'internal/db/'; Scope = 'store' }
@{ Prefix = 'internal/jobs/'; Scope = 'jobs' }
@{ Prefix = 'internal/pipeline/'; Scope = 'pipeline' }
@{ Prefix = 'internal/birdfmt/'; Scope = 'birdfmt' }
@{ Prefix = 'internal/birddeploy/'; Scope = 'birddeploy' }
@{ Prefix = 'internal/bundle/'; Scope = 'bundle' }
@{ Prefix = 'internal/signing/'; Scope = 'bundle' }
@{ Prefix = 'cmd/'; Scope = 'cmd' }
@{ Prefix = 'web/'; Scope = 'web' }
@{ Prefix = 'migrations/'; Scope = 'db' }
@{ Prefix = 'docs/'; Scope = 'docs' }
@{ Prefix = '.gitea/'; Scope = 'ci' }
@{ Prefix = 'deploy/'; Scope = 'deploy' }
@{ Prefix = '.cursor/'; Scope = 'chore' }
)
foreach ($rule in $rules) {
if ($p -eq $rule.Prefix.TrimEnd('/') -or $p.StartsWith($rule.Prefix)) {
return $rule.Scope
}
}
return 'chore'
}
function Get-ScopeSortOrder {
param([string]$Scope)
$order = @{
openapi = 10
httpapi = 20
store = 21
jobs = 22
pipeline = 30
birdfmt = 31
birddeploy = 32
bundle = 33
web = 40
ci = 50
deploy = 51
db = 52
docs = 60
cmd = 70
chore = 80
}
if ($order.ContainsKey($Scope)) { return $order[$Scope] }
return 99
}
function Invoke-Git {
param([string[]]$GitArgs)
$out = & git @GitArgs 2>&1
if ($LASTEXITCODE -ne 0) {
$msg = ($out | Out-String).Trim()
throw "git $($GitArgs -join ' ') failed: $msg"
}
return ($out | Out-String).TrimEnd()
}
function New-GitArgs {
param([string[]]$Base, [string[]]$Paths)
if ($Paths.Count -eq 0) { return $Base }
return $Base + '--' + $Paths
}
function Get-TruncatedDiff {
param(
[string[]]$Files,
[int]$MaxLines,
[ref]$TotalLinesUsed
)
if ($Files.Count -eq 0) { return '' }
$remaining = $MaxLinesTotal - $TotalLinesUsed.Value
if ($remaining -le 0) {
return '[diff truncated: global line budget exceeded]'
}
$cap = [Math]::Min($MaxLines, $remaining)
$diff = Invoke-Git -GitArgs (New-GitArgs -Base @(
'diff', '--cached', '--no-color', '--unified=3'
) -Paths $Files)
if ([string]::IsNullOrWhiteSpace($diff)) { return '' }
$lines = $diff -split "`n", -1
if ($lines.Count -le $cap) {
$TotalLinesUsed.Value += $lines.Count
return $diff
}
$truncated = ($lines[0..($cap - 1)] -join "`n") + "`n... [truncated: $($lines.Count - $cap) more lines]"
$TotalLinesUsed.Value += $cap
return $truncated
}
Test-GitRepository
$stagedRaw = Invoke-Git -GitArgs @('diff', '--cached', '--name-only')
if ([string]::IsNullOrWhiteSpace($stagedRaw)) {
[Console]::Error.WriteLine('staged-context: no staged changes (git index is empty)')
exit 1
}
$stagedFiles = @(
$stagedRaw -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
)
$stagedFiles = $stagedFiles | Sort-Object -Unique
$scopeBuckets = @{}
foreach ($f in $stagedFiles) {
$scope = Get-ScopeForPath -Path $f
if (-not $scopeBuckets.ContainsKey($scope)) {
$scopeBuckets[$scope] = [System.Collections.Generic.List[string]]::new()
}
$scopeBuckets[$scope].Add($f) | Out-Null
}
$totalLinesUsed = 0
$groups = New-Object System.Collections.Generic.List[object]
$scopeKeys = @($scopeBuckets.Keys | Sort-Object { Get-ScopeSortOrder $_ })
foreach ($scope in $scopeKeys) {
$files = @($scopeBuckets[$scope] | Sort-Object)
$stat = Invoke-Git -GitArgs (New-GitArgs -Base @('diff', '--cached', '--stat') -Paths $files)
$diffExcerpt = Get-TruncatedDiff -Files $files -MaxLines $MaxLinesPerGroup -TotalLinesUsed ([ref]$totalLinesUsed)
$groups.Add([ordered]@{
scope = $scope
files = $files
stat = $stat
diff_excerpt = $diffExcerpt
}) | Out-Null
}
$result = [ordered]@{
staged_count = $stagedFiles.Count
groups = $groups.ToArray()
}
$json = $result | ConvertTo-Json -Depth 6 -Compress:$false
# UTF-8 stdout for agents / ConvertFrom-Json
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
Write-Output $json
exit 0
+8
View File
@@ -16,6 +16,14 @@ if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*err\.Error\(\)' "$HTTPAPI"
FAIL=1
fi
echo "==> ERR-01: no err.Error() in 4xx writeProblem (store/cdn/csv)"
if grep -rE 'writeProblem\(w, http\.Status(NotFound|UnprocessableEntity|BadRequest).*, err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
FAIL=1
fi
if grep -rE 'writeStoreErr.*err\.Error|writeProblem.*Unprocessable.*err\.Error' "$HTTPAPI" 2>/dev/null; then
FAIL=1
fi
echo "==> ARCH-01: no SQL/pgx queries in httpapi"
if grep -rE 'pool\.(Query|Exec|QueryRow)|SELECT |INSERT INTO |UPDATE .* SET |DELETE FROM ' "$HTTPAPI" 2>/dev/null; then
FAIL=1
+2 -3
View File
@@ -14,7 +14,8 @@
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@internationalized/date": "^3.12.0",
@@ -3137,9 +3138,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+2 -1
View File
@@ -39,6 +39,7 @@
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}
}
+49
View File
@@ -0,0 +1,49 @@
/** Ответ GET /v1/version (см. docs/openapi.yaml VersionInfo). */
export type VersionInfo = {
version?: string;
api_version?: string;
git_sha?: string;
build_time?: string;
};
const PLACEHOLDER_SHA = new Set(['', 'unknown']);
function nonEmptyString(value: unknown): string {
if (typeof value === 'string') {
const s = value.trim();
return s;
}
if (value == null) return '';
return String(value).trim();
}
/** Semver или legacy api_version; пустая строка, если полей нет. */
export function resolveVersionString(info: VersionInfo | null | undefined): string {
if (!info) return '';
return nonEmptyString(info.version) || nonEmptyString(info.api_version);
}
/** Короткий git SHA; пусто для placeholder «unknown». */
export function resolveGitSha(info: VersionInfo | null | undefined): string {
if (!info) return '';
const sha = nonEmptyString(info.git_sha);
if (!sha || PLACEHOLDER_SHA.has(sha.toLowerCase())) return '';
return sha;
}
/** Заголовок карточки «Версия» на /monitoring. */
export function formatVersionHeadline(info: VersionInfo | null | undefined): string {
if (!info) return '—';
const ver = resolveVersionString(info);
const sha = resolveGitSha(info);
if (ver && sha) return `${ver} (${sha.slice(0, 12)})`;
if (ver) return ver;
if (sha) return sha.slice(0, 12);
return '—';
}
/** Подпись в сайдбаре (v1.2.3). */
export function formatVersionSidebarLabel(info: VersionInfo | null | undefined): string | null {
const ver = resolveVersionString(info);
return ver ? `v${ver}` : null;
}
@@ -0,0 +1,188 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, BgpCommunityCreate } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: BgpCommunity[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<BgpCommunity | null>(null);
let form = $state<BgpCommunityCreate>({ community: '', title: '' });
let saving = $state(false);
const columns = [
{
id: 'community',
label: 'Код сообщества',
sortable: true,
sortValue: (c: BgpCommunity) => c.community
},
{
id: 'title',
label: 'Название',
sortable: true,
sortValue: (c: BgpCommunity) => c.title ?? ''
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function displayName(c: BgpCommunity | null) {
if (!c) return '';
const t = c.title?.trim();
return t || c.community;
}
function openCreate() {
editTarget = null;
form = { community: '', title: '' };
dialogOpen = true;
}
function openEdit(c: BgpCommunity) {
editTarget = c;
form = { community: c.community, title: c.title ?? '' };
dialogOpen = true;
}
function requestDelete(c: BgpCommunity) {
void confirm({
title: `Удалить сообщество «${displayName(c)}»?`,
description: 'Это приведёт к удалению привязки во всех модулях.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/communities/${c.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.community.trim()) {
notify.error('Укажите community');
return;
}
saving = true;
try {
const body = { ...form, title: form.title?.trim() || undefined };
if (editTarget) {
await apiMutate(`/v1/communities/${editTarget.id}`, 'PATCH', body);
notify.success('Запись сообщества обновлена');
} else {
await apiMutate('/v1/communities', 'POST', body);
notify.success('Сообщество создано');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Сообщества BGP</CardTitle>
<CardDescription>Используются для тегирования префиксов в AS- и CDN-модулях</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(c) => c.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет сообществ BGP"
emptyDescription="Создайте первое сообщество для тегирования префиксов."
>
{#snippet cell({ row: c, column })}
{#if column.id === 'community'}
<span class="font-mono text-sm font-medium">{c.community}</span>
{:else if column.id === 'title'}
<span>{c.title?.trim() || '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{c.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(c)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(c)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle
>{editTarget ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Код сообщества" id="c-community" required>
<AppInput id="c-community" bind:value={form.community} placeholder="65001:120" />
</FormField>
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
<AppInput id="c-title" bind:value={form.title} placeholder="Название" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,178 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { DohProfile, DohProfileCreate } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: DohProfile[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DohProfile | null>(null);
let form = $state<DohProfileCreate & { timeout_ms?: number | null }>({
url: '',
timeout_ms: null,
vault_secret_ref: null
});
let saving = $state(false);
const columns = [
{ id: 'url', label: 'URL', sortable: true, sortValue: (d: DohProfile) => d.url },
{
id: 'timeout_ms',
label: 'Таймаут (мс)',
sortable: true,
sortValue: (d: DohProfile) => d.timeout_ms ?? 0
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function openCreate() {
editTarget = null;
form = { url: '', timeout_ms: null, vault_secret_ref: null };
dialogOpen = true;
}
function openEdit(d: DohProfile) {
editTarget = d;
form = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
dialogOpen = true;
}
function requestDelete(d: DohProfile) {
void confirm({
title: 'Удалить DoH профиль?',
description: d.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/doh-profiles/${d.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.url.trim()) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/doh-profiles/${editTarget.id}`, 'PATCH', form);
notify.success('DoH профиль обновлён');
} else {
await apiMutate('/v1/doh-profiles', 'POST', form);
notify.success('DoH профиль создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">DoH профили</CardTitle>
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(d) => d.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет DoH профилей"
emptyDescription="Добавьте DNS-over-HTTPS сервер для доменных модулей."
>
{#snippet cell({ row: d, column })}
{#if column.id === 'url'}
<span class="font-mono text-sm">{d.url}</span>
{:else if column.id === 'timeout_ms'}
<span class="text-muted-foreground">{d.timeout_ms ?? '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{d.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(d)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(d)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="URL" id="doh-url" required>
<AppInput id="doh-url" bind:value={form.url} placeholder="https://dns.google/dns-query" />
</FormField>
<FormField label="Таймаут (мс)" id="doh-timeout">
<AppInput id="doh-timeout" type="number" bind:value={form.timeout_ms} placeholder="5000" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,342 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: AsEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<AsEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
{
id: 'name',
label: 'Название AS',
sortable: true,
sortValue: (e: AsEntry) => e.asn_name ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
class: 'text-right'
},
{
id: 'updated',
label: 'Обновлено',
sortable: true,
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: AsEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: AsEntry) {
void confirm({
title: 'Удалить запись?',
description: `ASN: ${entry.asn}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные AS-записи?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено AS-записей: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">AS-записи</CardTitle>
<CardDescription>
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
(RIPEstat).
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет AS-записей"
emptyDescription="Добавьте ASN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все AS-записи"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать AS ${entry.asn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'asn'}
<span class="font-mono">{entry.asn}</span>
{:else if column.id === 'name'}
<span
class="max-w-[14rem] truncate text-sm text-muted-foreground"
title={entry.asn_name ?? ''}
>
{entry.asn_name?.trim() ? entry.asn_name : '—'}
</span>
{:else if column.id === 'prefixes'}
<span class="font-mono text-sm">
{entry.prefix_count != null ? entry.prefix_count : '—'}
</span>
{:else if column.id === 'updated'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(entry.asn_resolved_at)}
</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleAsEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,137 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: AsEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
const asn = Number(form.asn);
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
notify.error('Укажите корректный ASN (1–4294967295)');
return;
}
saving = true;
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
notify.success('Запись обновлена');
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
notify.success('Запись добавлена');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
bind:value={form.asn}
min={1}
max={4294967295}
/>
</div>
<div class="space-y-1.5">
<Label for="as-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="as-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,270 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
CdnPreviewResponse,
CdnSource,
CdnSourceCreate
} from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
normalizeCdnSourceKind,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: CdnSource | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let previewLoading = $state(false);
let previewItems = $state<string[]>([]);
let previewTotal = $state(0);
let previewTruncated = $state(false);
let previewError = $state<string | null>(null);
let previewOk = $state(false);
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null
});
let initKey = $state('');
function clearPreview() {
previewLoading = false;
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewError = null;
previewOk = false;
}
function resetForm() {
clearPreview();
form = edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function previewCdn() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
previewLoading = true;
previewError = null;
previewOk = false;
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
}
);
previewItems = res.items;
previewTotal = res.total;
previewTruncated = res.truncated;
previewOk = true;
} catch (e) {
previewError = e instanceof Error ? e.message : String(e);
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewOk = false;
} finally {
previewLoading = false;
}
}
async function save() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
};
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
notify.success('Источник обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
notify.success('Источник добавлен');
}
clearPreview();
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
clearPreview();
onClose();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="cdn-url">URL</Label>
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
</div>
<div class="space-y-1.5">
<Label for="cdn-kind">Тип источника</Label>
<Select
type="single"
value={form.source_kind}
onValueChange={(v) => {
form.source_kind = v || 'plaintext';
}}
>
<SelectTrigger id="cdn-kind" class="w-full">
{form.source_kind === 'json' ? 'json' : 'plaintext'}
</SelectTrigger>
<SelectContent>
<SelectItem value="plaintext">plaintext</SelectItem>
<SelectItem value="json">json</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
bind:value={form.prefix_path}
/>
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
<p class="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
{/if}
</div>
<div class="space-y-1.5">
<Label for="cdn-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="cdn-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onclick={previewCdn}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{#if previewError}
<span class="text-sm text-destructive">{previewError}</span>
{:else if previewOk}
<span class="text-sm text-muted-foreground">
Всего: {previewTotal}{#if previewTruncated}
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
</span>
{/if}
</div>
{#if previewItems.length}
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{#each previewItems as item, i (`${i}-${item}`)}
<li class="py-0.5">{item}</li>
{/each}
</ul>
{/if}
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,237 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
normalizeCdnSourceKind
} from '$lib/components/modules/module-helpers.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
moduleId: string;
sources: CdnSource[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<CdnSource | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
const activeSelected = $derived.by(() => {
const allowed = new Set(sources.map((s) => s.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
{ id: 'community', label: 'Community' },
{
id: 'interval',
label: 'Интервал',
sortable: true,
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
},
{
id: 'refreshed',
label: 'Последнее обновление',
sortable: true,
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
},
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(src: CdnSource) {
editTarget = src;
dialogOpen = true;
}
function requestDelete(src: CdnSource) {
void confirm({
title: 'Удалить CDN-источник?',
description: src.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные CDN-источники?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">CDN-источники</CardTitle>
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={sources}
rowKey={(s) => s.id}
{loading}
emptyTitle="Нет CDN-источников"
emptyDescription="Добавьте URL для загрузки списков CIDR."
>
{#snippet toolbar()}
{#if sources.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все CDN-источники"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: src, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(src.id)}
aria-label="Выбрать CDN-источник"
onCheckedChange={() => toggleSelection(src.id)}
/>
{:else if column.id === 'url'}
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
{:else if column.id === 'kind'}
<div class="flex flex-col gap-0.5">
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
{#if src.prefix_path?.trim()}
<span
class="font-mono text-xs break-all text-muted-foreground"
title={src.prefix_path}>{src.prefix_path}</span
>
{/if}
</div>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(src.community_id, communities)}
</span>
{:else if column.id === 'interval'}
<span class="text-sm text-muted-foreground">
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(src.last_refreshed_at)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(src)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleCdnSourceDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,144 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ModuleCreate } from '$lib/api/types.js';
import { moduleTypeRu } from '$lib/ui-labels.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
onClose: () => void;
onCreated: () => void | Promise<void>;
};
let { open = $bindable(), onClose, onCreated }: Props = $props();
let saving = $state(false);
let form = $state<ModuleCreate>({
type: 'AS_PREFIXES',
name: '',
enabled: true,
priority: 0
});
const moduleTypes = [
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
] as const;
function resetForm() {
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
}
async function create() {
if (!form.name.trim()) {
notify.error('Укажите название модуля');
return;
}
saving = true;
try {
await apiMutate('/v1/modules', 'POST', form);
notify.success('Модуль создан');
open = false;
resetForm();
await onCreated();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
onClose();
resetForm();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="m-name">Название</Label>
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
</div>
<div class="space-y-1.5">
<Label for="m-type">Тип</Label>
<Select
type="single"
value={form.type}
onValueChange={(v) => {
if (v) form.type = v as typeof form.type;
}}
>
<SelectTrigger id="m-type" class="w-full">
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
</SelectTrigger>
<SelectContent>
{#each moduleTypes as t (t.value)}
<SelectItem value={t.value}>{t.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="m-priority">Приоритет</Label>
<Input id="m-priority" type="number" bind:value={form.priority} />
</div>
<div class="space-y-1.5">
<Label for="m-interval">Интервал (сек)</Label>
<Input
id="m-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
</div>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Модуль участвует в сборке ревизий, если включён.
</p>
</div>
<Switch
id="m-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ModuleRow } from '$lib/api/types.js';
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Blocks from '@lucide/svelte/icons/blocks';
type Props = {
mod: ModuleRow;
refreshing: boolean;
onRefresh: () => void;
onEdit: () => void;
onDelete: () => void;
};
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
</script>
<div class="flex min-w-0 items-start gap-2">
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
<ArrowLeft class="size-4" />
</Button>
<PageHeader
class="min-w-0 flex-1"
title={mod.name}
description={mod.id}
icon={Blocks}
iconClass="bg-chart-2/15 text-chart-2"
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
{moduleEnabledRu(!!mod.enabled)}
</Badge>
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" size="sm" onclick={() => onEdit()}>
<Pencil />
Редактировать
</Button>
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
<Trash2 />
Удалить
</Button>
</div>
{/snippet}
</PageHeader>
</div>
@@ -0,0 +1,302 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: DomainEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DomainEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: DomainEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: DomainEntry) {
void confirm({
title: 'Удалить домен?',
description: entry.fqdn,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные домены?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Домены</CardTitle>
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет доменов"
emptyDescription="Добавьте FQDN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все домены"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать домен ${entry.fqdn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'fqdn'}
<span class="font-mono">{entry.fqdn}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleDomainEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,120 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: DomainEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
notify.success('Домен обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
notify.success('Домен добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="dom-fqdn">FQDN</Label>
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
</div>
<div class="space-y-1.5">
<Label for="dom-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="dom-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,294 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModulePatch,
ModuleRow
} from '$lib/api/types.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
moduleDohProfileIds,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
{
value: 'primary_only',
label: 'Только первый',
hint: 'Используется первый выбранный DoH-профиль.'
},
{
value: 'failover',
label: 'Резервирование',
hint: 'Профили по порядку до первого успешного ответа.'
},
{
value: 'union',
label: 'Объединение',
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
}
];
type Props = {
open: boolean;
mod: ModuleRow;
moduleId: string;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let {
open = $bindable(),
mod,
moduleId,
communities,
dohProfiles,
onSaved,
onClose
}: Props = $props();
let editForm = $state<ModulePatch>({});
let editSaving = $state(false);
let initKey = $state('');
function resetEditForm() {
editForm = {
name: mod.name,
enabled: mod.enabled,
priority: mod.priority,
refresh_interval_sec: mod.refresh_interval_sec,
cron_expr: mod.cron_expr,
default_community_id: mod.default_community_id,
doh_profile_ids: moduleDohProfileIds(mod),
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
};
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = mod.id;
if (nextKey !== initKey) {
initKey = nextKey;
resetEditForm();
}
});
function toggleEditDohProfile(id: string, checked: boolean) {
let ids = [...(editForm.doh_profile_ids ?? [])];
if (checked) {
if (!ids.includes(id)) ids.push(id);
} else {
ids = ids.filter((x) => x !== id);
}
editForm = { ...editForm, doh_profile_ids: ids };
}
function isEditDohProfileSelected(id: string): boolean {
return (editForm.doh_profile_ids ?? []).includes(id);
}
async function saveMod() {
editSaving = true;
try {
const cron =
typeof editForm.cron_expr === 'string' ? editForm.cron_expr.trim() : editForm.cron_expr;
const intervalRaw = editForm.refresh_interval_sec;
const interval =
intervalRaw === null || intervalRaw === undefined ? null : Number(intervalRaw);
const payload: ModulePatch = {
...editForm,
cron_expr: cron ? cron : null,
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
default_community_id: fromNullableSelect(
nullableSelectValue(editForm.default_community_id)
),
doh_profile_ids: editForm.doh_profile_ids ?? [],
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
};
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
notify.success('Модуль обновлён');
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
editSaving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Редактировать модуль</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="e-name">Название</Label>
<Input id="e-name" bind:value={editForm.name} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="e-priority">Приоритет</Label>
<Input id="e-priority" type="number" bind:value={editForm.priority} />
</div>
<div class="space-y-1.5">
<Label for="e-interval">Интервал (сек)</Label>
<Input id="e-interval" type="number" bind:value={editForm.refresh_interval_sec} />
</div>
</div>
<div class="space-y-1.5">
<Label for="e-cron">Cron-выражение</Label>
<Input id="e-cron" placeholder="0 */6 * * *" bind:value={editForm.cron_expr} />
</div>
<div class="space-y-1.5">
<Label for="e-comm">Community по умолчанию</Label>
<Select
type="single"
value={nullableSelectValue(editForm.default_community_id)}
onValueChange={(v) => {
editForm.default_community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="e-comm" class="w-full">
{editForm.default_community_id
? communityLabel(editForm.default_community_id, communities)
: 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm.default_community_id = null;
}}
>
Сбросить
</Button>
</div>
{#if mod.type === 'DOMAINS'}
<div class="space-y-1.5">
<Label for="e-doh-policy">Политика DoH</Label>
<Select
type="single"
value={editForm.doh_resolver_policy ?? 'primary_only'}
onValueChange={(v) => {
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
}}
>
<SelectTrigger id="e-doh-policy" class="w-full">
{dohPolicyRu(editForm.doh_resolver_policy)}
</SelectTrigger>
<SelectContent>
{#each dohPolicyOptions as opt (opt.value)}
<SelectItem value={opt.value}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{dohPolicyOptions.find(
(o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only')
)?.hint}
</p>
</div>
<div class="space-y-2">
<Label>DoH-профили</Label>
<p class="text-xs text-muted-foreground">
Порядок выбора = порядок в списке (сверху вниз).
</p>
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
{#each dohProfiles as d (d.id)}
<label class="flex items-start gap-2 text-sm">
<Checkbox
checked={isEditDohProfileSelected(d.id)}
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
/>
<span class="min-w-0 break-all">
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
{#if d.name?.trim()}
<span class="block font-mono text-xs text-muted-foreground">{d.url}</span>
{/if}
</span>
</label>
{:else}
<p class="text-xs text-muted-foreground">Нет профилей — создайте в справочниках.</p>
{/each}
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm = { ...editForm, doh_profile_ids: [] };
}}
>
Сбросить профили
</Button>
</div>
{/if}
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="e-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Отключённые модули не участвуют в обновлении конфигурации.
</p>
</div>
<Switch
id="e-enabled"
class="shrink-0"
checked={editForm.enabled !== false}
onCheckedChange={(v) => {
editForm = { ...editForm, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={saveMod} disabled={editSaving}>
{editSaving ? 'Сохранение…' : 'Сохранить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,115 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: IpRangeEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
let initKey = $state('');
function resetForm() {
form = edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', form);
notify.success('Диапазон обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', form);
notify.success('Диапазон добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="ip-prefix">Префикс (CIDR)</Label>
<Input id="ip-prefix" placeholder="203.0.113.0/24" bind:value={form.prefix} />
</div>
<div class="space-y-1.5">
<Label for="ip-comm">Community (обязательно)</Label>
<Select
type="single"
value={form.community_id}
onValueChange={(v) => {
form.community_id = v;
}}
>
<SelectTrigger id="ip-comm" class="w-full">
{form.community_id
? communityLabel(form.community_id, communities)
: 'Выберите community'}
</SelectTrigger>
<SelectContent>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,310 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: IpRangeEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<IpRangeEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{
id: 'prefix',
label: 'Префикс (CIDR)',
sortable: true,
sortValue: (e: IpRangeEntry) => e.prefix
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: IpRangeEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: IpRangeEntry) {
void confirm({
title: 'Удалить диапазон?',
description: entry.prefix,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(
`/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
'DELETE',
undefined,
{ idempotent: false }
);
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные диапазоны?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено диапазонов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">IP-диапазоны</CardTitle>
<CardDescription>Статические CIDR для анонса.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет диапазонов"
emptyDescription="Добавьте CIDR или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все диапазоны"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать диапазон ${entry.prefix}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'prefix'}
<span class="font-mono">{entry.prefix}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleIpRangeEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,155 @@
<script lang="ts">
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
import { formatDateTime, moduleIntervalLabel } from '$lib/modules/display.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds
} from '$lib/components/modules/module-helpers.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/ui/core/card/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
import Timer from '@lucide/svelte/icons/timer';
import ShieldCheck from '@lucide/svelte/icons/shield-check';
import Network from '@lucide/svelte/icons/network';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
type Props = {
mod: ModuleRow | null;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
asEntries: AsEntry[];
loading?: boolean;
};
let { mod, communities, dohProfiles, asEntries, loading = false }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-5',
bg: 'bg-chart-5/5',
iconBg: 'bg-chart-5/15',
iconText: 'text-chart-5'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-muted-foreground',
bg: 'bg-muted/30',
iconBg: 'bg-muted',
iconText: 'text-muted-foreground'
}
] as const;
const kpiCards = $derived.by(() => {
if (!mod) return [];
const dohIds = moduleDohProfileIds(mod);
return [
{
id: 'priority',
label: 'Приоритет',
value: String(mod.priority ?? 0),
description: 'порядок в сборке ревизии',
icon: ArrowDownUp,
accent: statAccents[0]
},
{
id: 'interval',
label: 'Интервал',
value: moduleIntervalLabel(mod),
description: 'refresh_interval_sec / cron',
icon: Timer,
accent: statAccents[1],
mono: true
},
{
id: 'doh',
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
description:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: Network,
accent: statAccents[2]
},
{
id: 'community',
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
description: 'для записей без своего community',
icon: ShieldCheck,
accent: statAccents[3]
},
{
id: 'refreshed',
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
description:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: RefreshCw,
accent: statAccents[4]
}
];
});
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
{#if loading}
{#each Array(5) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
<CardHeader class="pb-2">
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
class={cn('flex size-7 shrink-0 items-center justify-center rounded-md', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-3.5', a.iconText)} />
</span>
{card.label}
</p>
<CardTitle
class={cn('text-base font-semibold break-all', card.mono ? 'font-mono text-sm' : '')}
>
{card.value}
</CardTitle>
</CardHeader>
<CardContent>
<p class="line-clamp-2 text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -0,0 +1,115 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
type Props = {
mod: ModuleRow;
asEntries: AsEntry[];
cdnSources: CdnSource[];
domainEntries: DomainEntry[];
ipEntries: IpRangeEntry[];
};
let { mod, asEntries, cdnSources, domainEntries, ipEntries }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
</script>
<Card>
<CardHeader>
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
<CardDescription>
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для
refresh/агрегации.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-2">
{#if mod.type === 'DOMAINS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
<p class="text-xs text-muted-foreground">
После refresh домены резолвятся в IP и конвертируются в префиксы.
</p>
<div class="mt-2 space-y-1">
{#each domainEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет доменов</p>
{/each}
{#if domainEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {domainEntries.length - 8}</p>
{/if}
</div>
</div>
{:else if mod.type === 'AS_PREFIXES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
<p class="text-xs text-muted-foreground">
Счётчик префиксов обновляется после успешного refresh (RIPEstat).
</p>
<div class="mt-2 space-y-1">
<p class="text-xs">
Всего ASN: <span class="font-semibold">{asEntries.length}</span>
</p>
<p class="text-xs">
Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span>
</p>
</div>
</div>
{:else if mod.type === 'CDN_CIDRS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
<p class="text-xs text-muted-foreground">
Каждый URL поставляет список CIDR для агрегации.
</p>
<div class="mt-2 space-y-1">
{#each cdnSources.slice(0, 6) as src (src.id)}
<p class="font-mono text-xs break-all">{src.url}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет CDN источников</p>
{/each}
{#if cdnSources.length > 6}
<p class="text-xs text-muted-foreground">…и ещё {cdnSources.length - 6}</p>
{/if}
</div>
</div>
{:else if mod.type === 'IP_RANGES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">IP ranges для агрегации</p>
<p class="text-xs text-muted-foreground">
Статические CIDR, которые попадают в итоговую ревизию.
</p>
<div class="mt-2 space-y-1">
{#each ipEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs">{entry.prefix}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет диапазонов</p>
{/each}
{#if ipEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {ipEntries.length - 8}</p>
{/if}
</div>
</div>
{/if}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Результат операции</p>
<p class="text-xs text-muted-foreground">
Подробный результат по конкретному запуску refresh смотрите в Операции → Задачи →
module_refresh: там отображаются источники, количество префиксов и итог агрегации.
</p>
<Button variant="link" class="mt-2 h-auto p-0" href={resolve('/operations?tab=jobs')}>
Открыть задачи
</Button>
</div>
</CardContent>
</Card>
@@ -0,0 +1,60 @@
import type { BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
export const NONE_OPTION = '__none__';
export function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
}
export function sanitizeFilenamePart(v: string): string {
const cleaned = v
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '');
return cleaned || 'module';
}
export function communityLabel(id: string | null, communities: BgpCommunity[]): string {
if (!id) return '—';
const c = communities.find((x) => x.id === id);
if (!c) return id.slice(0, 8) + '…';
const t = c.title?.trim();
return t || c.community;
}
export function communityOptionLabel(c: BgpCommunity): string {
const t = c.title?.trim();
return t || c.community;
}
export function nullableSelectValue(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return NONE_OPTION;
return value;
}
export function fromNullableSelect(value: string): string | null {
if (value === NONE_OPTION || value === '') return null;
return value;
}
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
if (!modRow) return [];
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
}
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id);
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
}
export function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
const validIds = new Set(existingIds);
return new Set([...selected].filter((id) => validIds.has(id)));
}
@@ -0,0 +1,290 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { PeerRow, BgpPeerCreate, SpeakerRow } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: PeerRow[];
speakers: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let {
items,
speakers,
loading = false,
initialLoading = false,
error = null,
onRefresh
}: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<PeerRow | null>(null);
let form = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
name: '',
neighbor: '',
remote_asn: 0,
bgp_speaker_id: null,
enabled: true
});
let saving = $state(false);
let toggleId = $state<string | null>(null);
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (p: PeerRow) => p.name ?? '' },
{ id: 'neighbor', label: 'Адрес', sortable: true, sortValue: (p: PeerRow) => p.neighbor },
{
id: 'remote_asn',
label: 'Remote ASN',
sortable: true,
sortValue: (p: PeerRow) => p.remote_asn ?? 0
},
{ id: 'enabled', label: 'Вкл.', class: 'w-[4.5rem] text-center' },
{ id: 'session_state', label: 'Состояние сессии' },
{ id: 'speaker', label: 'Спикер' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function speakerLabelById(id: string | null | undefined) {
if (!id) return '—';
return speakerById.get(id)?.endpoint ?? id;
}
function sessionBadge(state: string) {
if (state === 'Established') return 'default';
if (state === 'Active' || state === 'Connect') return 'secondary';
return 'outline';
}
function openCreate() {
editTarget = null;
form = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
dialogOpen = true;
}
function openEdit(p: PeerRow) {
editTarget = p;
form = {
name: p.name ?? '',
neighbor: p.neighbor,
remote_asn: p.remote_asn ?? 0,
bgp_speaker_id: p.bgp_speaker_id,
enabled: p.enabled !== false
};
dialogOpen = true;
}
function requestDelete(p: PeerRow) {
void confirm({
title: 'Удалить пира?',
description: p.neighbor,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/peers/${p.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Пир удалён');
await onRefresh();
}
});
}
async function setEnabled(p: PeerRow, enabled: boolean) {
toggleId = p.id;
try {
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
notify.success(enabled ? 'Пир включён' : 'Пир отключён');
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
toggleId = null;
}
}
async function save() {
if (!form.neighbor.trim()) {
notify.error('Укажите адрес соседа');
return;
}
if (!form.remote_asn || form.remote_asn <= 0) {
notify.error('Remote ASN должен быть больше 0');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/peers/${editTarget.id}`, 'PATCH', form);
notify.success('Пир обновлён');
} else {
await apiMutate('/v1/peers', 'POST', {
...form,
enabled: form.enabled !== false
});
notify.success('Пир создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">BGP-пиры</CardTitle>
<CardDescription>Настройка BGP-соседей и привязка к спикерам</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(p) => p.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет BGP-пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
>
{#snippet cell({ row: p, column })}
{#if column.id === 'name'}
<span>{p.name?.trim() || '—'}</span>
{:else if column.id === 'neighbor'}
<span class="font-mono text-sm">{p.neighbor}</span>
{:else if column.id === 'remote_asn'}
<span class="font-mono text-sm">{p.remote_asn ?? '—'}</span>
{:else if column.id === 'enabled'}
<div class="flex justify-center">
<Switch
size="sm"
checked={p.enabled !== false}
disabled={loading || toggleId === p.id}
onCheckedChange={(v) => setEnabled(p, v)}
/>
</div>
{:else if column.id === 'session_state'}
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
{:else if column.id === 'speaker'}
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(p)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Имя пира (опционально)" id="p-name">
<AppInput id="p-name" placeholder="Core-RTR-1" bind:value={form.name} />
</FormField>
<FormField label="Адрес соседа" id="p-neighbor" required>
<AppInput id="p-neighbor" placeholder="192.0.2.1" bind:value={form.neighbor} />
</FormField>
<FormField label="Remote ASN" id="p-asn" required>
<AppInput id="p-asn" type="number" placeholder="65000" bind:value={form.remote_asn} />
</FormField>
<FormField label="Спикер (опционально)" id="p-speaker">
<Select
type="single"
value={form.bgp_speaker_id ?? ''}
onValueChange={(v) => {
form = { ...form, bgp_speaker_id: v || null };
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each speakers as s (s.id)}
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="p-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Switch
id="p-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,177 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play';
type Props = {
items: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<SpeakerRow | null>(null);
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
let saving = $state(false);
let applyingId = $state<string | null>(null);
const columns = [
{
id: 'endpoint',
label: 'Endpoint',
sortable: true,
sortValue: (s: SpeakerRow) => s.endpoint
},
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
{ id: 'actions', label: '', class: 'w-32' }
] as const;
function openCreate() {
editTarget = null;
form = { endpoint: '', role: 'operator' };
dialogOpen = true;
}
function openEdit(s: SpeakerRow) {
editTarget = s;
form = { endpoint: s.endpoint, role: s.role };
dialogOpen = true;
}
async function applySpeaker(id: string) {
applyingId = id;
try {
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
notify.success('Apply запущен');
} catch (e) {
notifyApiError(e);
} finally {
applyingId = null;
}
}
async function save() {
if (!form.endpoint.trim()) {
notify.error('Укажите endpoint');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
notify.success('Спикер обновлён');
} else {
await apiMutate('/v1/speakers', 'POST', form);
notify.success('Спикер создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(s) => s.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
>
{#snippet cell({ row: s, column })}
{#if column.id === 'endpoint'}
<span class="font-mono text-sm">{s.endpoint}</span>
{:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'last_applied_revision_id'}
<span class="font-mono text-xs text-muted-foreground">
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button
variant="outline"
size="xs"
title="Запустить применение ревизии на спикере"
onclick={() => applySpeaker(s.id)}
disabled={applyingId === s.id}
>
<Play class="size-3" />
{applyingId === s.id ? 'Apply…' : 'Apply'}
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
<Pencil class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Endpoint" id="s-endpoint" required>
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
</FormField>
<FormField label="Роль" id="s-role">
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1,13 +1,15 @@
<script lang="ts">
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { formatDateTime } from '$lib/modules/display.js';
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
type Props = {
@@ -19,7 +21,6 @@
onDiffRevAChange: (value: string) => void;
onDiffRevBChange: (value: string) => void;
onLoadDiff: () => void;
formatDate: (d?: string | null) => string;
};
let {
@@ -30,11 +31,13 @@
diffLoading,
onDiffRevAChange,
onDiffRevBChange,
onLoadDiff,
formatDate
onLoadDiff
}: Props = $props();
/** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */
function revisionLabel(rev: RevisionRow): string {
return `${rev.id.slice(0, 8)}… (${formatDateTime(rev.created_at)})`;
}
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
@@ -82,26 +85,36 @@
</CardHeader>
<CardContent class="min-h-0 space-y-4">
<div class="flex flex-col gap-2 sm:flex-row">
<select
value={diffRevA}
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Ревизия A</option>
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
{/each}
</select>
<select
value={diffRevB}
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="">Ревизия B</option>
{#each revisions as rev (rev.id)}
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
{/each}
</select>
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevA
? revisions.find((r) => r.id === diffRevA)
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
: diffRevA
: 'Ревизия A'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия A</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevB
? revisions.find((r) => r.id === diffRevB)
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
: diffRevB
: 'Ревизия B'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия B</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
size="sm"
class="shrink-0 self-start sm:self-auto"
@@ -117,10 +130,9 @@
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
style="scrollbar-gutter: stable;"
>
<!-- + Добавлено -->
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-success"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
<span>Добавлено ({addedSorted.length})</span>
@@ -137,7 +149,7 @@
<tbody>
{#each addedSorted as line, i (`a-${i}-${line}`)}
<tr
class="border-b border-l-2 border-border/50 border-l-green-500/80 bg-green-500/[0.08] hover:bg-muted/30 dark:bg-green-500/15"
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
@@ -157,10 +169,9 @@
</div>
</div>
<!-- − Удалено -->
<div class="flex min-h-0 min-w-0 flex-col">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-destructive"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">−</span>
<span>Удалено ({removedSorted.length})</span>
@@ -177,7 +188,7 @@
<tbody>
{#each removedSorted as line, i (`r-${i}-${line}`)}
<tr
class="border-b border-l-2 border-border/50 border-l-red-500/80 bg-red-500/[0.08] hover:bg-muted/30 dark:bg-red-500/15"
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import Filter from '@lucide/svelte/icons/filter';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
@@ -2,15 +2,17 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { JobRow } from '$lib/api/types.js';
import type { JobDetailedReport, JobLogEntry } from './types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import X from '@lucide/svelte/icons/x';
@@ -49,7 +51,6 @@
getJobLogEntries: (job: JobRow) => JobLogEntry[];
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
formatDate: (d?: string | null) => string;
};
let {
@@ -69,8 +70,7 @@
isJobExpanded,
getJobLogEntries,
getJobLogTotal,
jobStatusVariant,
formatDate
jobStatusVariant
}: Props = $props();
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
@@ -178,7 +178,7 @@
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
Создана
</p>
<p class="mt-1">{formatDate(job.created_at)}</p>
<p class="mt-1">{formatDateTime(job.created_at)}</p>
</div>
<div
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
@@ -187,7 +187,7 @@
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
Запущена
</p>
<p class="mt-1">{formatDate(job.started_at)}</p>
<p class="mt-1">{formatDateTime(job.started_at)}</p>
</div>
<div
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
@@ -196,7 +196,7 @@
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
Завершена
</p>
<p class="mt-1">{formatDate(job.finished_at)}</p>
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
</div>
</div>
</div>
@@ -450,9 +450,14 @@
{/if}
</div>
{:else}
<div class="text-muted-foreground py-10 text-center text-sm">
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
</div>
{#if jobsLoading}
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
{:else}
<EmptyState
title="Нет задач"
description="Задачи появятся после refresh, apply или rollback."
/>
{/if}
{/each}
</CardContent>
</Card>
@@ -1,8 +1,8 @@
<script lang="ts">
import type { BirdStatus } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Card } from '$lib/ui/core/card/index.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
@@ -114,9 +114,14 @@
</div>
<p class="font-semibold">Состояние BIRD</p>
{#if birdStatus}
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}
>{birdHealthyShortLabel(birdStatus.healthy)}</Badge
<Badge
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
class={birdStatus.healthy === true
? 'border-success/30 bg-success/15 text-success'
: undefined}
>
{birdHealthyShortLabel(birdStatus.healthy)}
</Badge>
{/if}
</div>
<p class="max-w-[56ch] text-sm text-foreground/80">
@@ -1,21 +1,15 @@
<script lang="ts">
import type { RevisionRow } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Undo from '@lucide/svelte/icons/undo';
import Eye from '@lucide/svelte/icons/eye';
@@ -28,7 +22,6 @@
onOpenPreview: (rev: RevisionRow) => void;
onRollbackRequest: (rev: RevisionRow) => void;
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
formatDate: (d?: string | null) => string;
};
let {
@@ -37,9 +30,31 @@
onReload,
onOpenPreview,
onRollbackRequest,
onDownloadDiagnosticLog,
formatDate
onDownloadDiagnosticLog
}: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'hash', label: 'Хэш' },
{ id: 'actions', label: '', class: 'w-32' }
] as const;
</script>
<Card>
@@ -61,55 +76,45 @@
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="min-w-0 p-0">
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
<Table class="min-w-[44rem]">
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Хэш</TableHead>
<TableHead class="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each revisions as rev (rev.id)}
<TableRow>
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
<TableCell>{rev.materialized_prefix_count}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}…</TableCell
>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={revisions}
rowKey={(rev) => rev.id}
loading={revLoading}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
{:else if column.id === 'created'}
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
{:else if column.id === 'prefixes'}
{rev.materialized_prefix_count}
{:else if column.id === 'hash'}
<span class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}…</span
>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -6,9 +6,9 @@
getCoreRowModel,
getPaginationRowModel
} from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { createSvelteTable, FlexRender } from '$lib/ui/core/data-table/index.js';
import * as Table from '$lib/ui/core/table/index.js';
import { Button } from '$lib/ui/core/button/index.js';
type Props = {
rows: RowData[];
@@ -0,0 +1,94 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { JobRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ExternalLink from '@lucide/svelte/icons/external-link';
type Props = {
items: JobRow[];
moduleNameById: ReadonlyMap<string, string>;
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let {
items,
moduleNameById,
loading = false,
initialLoading = false,
error = null
}: Props = $props();
const columns = [
{ id: 'kind', label: 'Вид', sortable: true, sortValue: (j: JobRow) => j.kind },
{
id: 'status',
label: 'Статус',
sortable: true,
sortValue: (j: JobRow) => j.status
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (j: JobRow) => j.created_at ?? ''
},
{ id: 'actions', label: '', class: 'w-10' }
] as const;
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Последние задачи</CardTitle>
<CardDescription>Фоновые задачи ingest, refresh и apply</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations?tab=jobs')}>
Все
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(j) => j.job_id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет задач"
emptyDescription="Задачи появятся после refresh или деплоя."
>
{#snippet cell({ row: j, column })}
{#if column.id === 'kind'}
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
{:else if column.id === 'status'}
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
{:else if column.id === 'created'}
<span class="text-xs whitespace-nowrap text-muted-foreground">
{formatDateTime(j.created_at)}
</span>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
<ExternalLink class="size-3.5" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -0,0 +1,89 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { RevisionRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ExternalLink from '@lucide/svelte/icons/external-link';
type Props = {
items: RevisionRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let { items, loading = false, initialLoading = false, error = null }: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'actions', label: '', class: 'w-10' }
] as const;
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Последние ревизии</CardTitle>
<CardDescription>Снимки конфигурации BIRD после обновления модулей</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations')}>
Все
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(rev) => rev.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
{:else if column.id === 'created'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(rev.created_at)}
</span>
{:else if column.id === 'prefixes'}
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
<ExternalLink class="size-3.5" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
+43
View File
@@ -0,0 +1,43 @@
import type { ModuleRow } from '$lib/api/types.js';
/** Форматирование ISO-даты для таблиц модулей и расписания. */
export function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
/** Подпись интервала refresh: секунды, cron или комбинация. */
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
const raw = moduleRow.refresh_interval_sec as unknown;
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null;
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
if (cron) return cron;
if (intervalLabel) return intervalLabel;
return '—';
}
export function moduleTypeBadgeVariant(
type: string
): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (type) {
case 'AS_PREFIXES':
return 'default';
case 'CDN_CIDRS':
return 'secondary';
case 'DOMAINS':
return 'outline';
case 'IP_RANGES':
return 'outline';
default:
return 'outline';
}
}
+169
View File
@@ -0,0 +1,169 @@
/** Pure-helpers для страницы /monitoring. */
export type OverallStatus = 'ok' | 'warn' | 'error' | 'unknown';
export type JobsKpi = { running: number; failed: number; total: number };
export type ReadyStatus = { status: string; checks?: Record<string, unknown> };
export type HealthStatus = { ok: boolean; status?: string; error?: string };
export type CheckBadge = {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
class?: string;
hint?: string;
};
export function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim() !== '') return error.message;
return 'Ошибка запроса';
}
export function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
let running = 0;
let failed = 0;
for (const row of items) {
const status = String(row.status ?? '').toLowerCase();
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
running += 1;
}
if (status === 'failed' || status === 'error' || status === 'canceled') {
failed += 1;
}
}
return { running, failed, total: items.length };
}
export function deriveOverallStatus(input: {
health: HealthStatus | null;
ready: ReadyStatus | null;
jobs: JobsKpi | null;
birdConfigured: boolean;
birdHealthy: boolean | null;
}): OverallStatus {
const { health, ready, jobs, birdConfigured, birdHealthy } = input;
if (health === null && ready === null && jobs === null) return 'unknown';
if (!health?.ok) return 'error';
if (ready !== null && ready.status !== 'ready') return 'error';
if (jobs !== null && jobs.failed > 0) return 'warn';
if (birdConfigured && birdHealthy === false) return 'warn';
return 'ok';
}
export function overallStatusLabel(status: OverallStatus): string {
switch (status) {
case 'ok':
return 'В норме';
case 'warn':
return 'Внимание';
case 'error':
return 'Ошибка';
default:
return 'Нет данных';
}
}
export function overallStatusHint(
status: OverallStatus,
input: {
healthOk: boolean | undefined;
jobsFailed: number;
}
): string {
if (status === 'unknown') return 'Нет данных. Запустите обновление.';
if (status === 'error') {
if (!input.healthOk) return 'Проверьте доступность API и логи сервиса.';
return 'Readiness не в норме: проверьте postgres/store/jobs.';
}
if (status === 'warn') {
if (input.jobsFailed > 0) return 'Есть ошибки в задачах: откройте операции и последние jobs.';
return 'Проверьте BGP-сессии и вывод birdc.';
}
return 'Критичных отклонений не обнаружено.';
}
export function overallBadgeVariant(status: OverallStatus): CheckBadge['variant'] {
if (status === 'ok') return 'default';
if (status === 'warn') return 'secondary';
if (status === 'error') return 'destructive';
return 'outline';
}
export function overallBadgeClass(status: OverallStatus): string | undefined {
if (status === 'ok') return 'border-success/30 bg-success/15 text-success';
if (status === 'warn') return 'border-warning/30 bg-warning/15 text-warning';
return undefined;
}
/** Нормализация значений ready.checks и health/readiness в badge. */
export function checkStatusBadge(value: unknown): CheckBadge {
const raw = String(value ?? '').trim();
const lower = raw.toLowerCase();
if (lower === 'ok' || lower === 'ready' || lower === 'true' || lower === 'up') {
return {
label: 'OK',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
if (lower === 'memory') {
return {
label: 'In-memory',
variant: 'secondary',
hint: 'Очередь задач в памяти процесса, не shared между воркерами.'
};
}
if (
lower === 'failed' ||
lower === 'false' ||
lower === 'error' ||
lower === 'down' ||
lower === 'unavailable'
) {
return { label: 'Ошибка', variant: 'destructive' };
}
if (raw === '') {
return { label: '—', variant: 'outline' };
}
return { label: raw, variant: 'outline' };
}
/** Человекочитаемое имя проверки readiness. */
export function checkDisplayName(key: string): string {
switch (key) {
case 'postgres':
return 'PostgreSQL';
case 'store':
return 'Хранилище';
case 'jobs':
return 'Очередь задач';
default:
return key;
}
}
export function livenessBadge(health: HealthStatus | null): CheckBadge {
if (health === null) return { label: '—', variant: 'outline' };
if (health.ok) {
return {
label: 'В норме',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
return { label: 'Недоступен', variant: 'destructive' };
}
export function readinessBadge(ready: ReadyStatus | null): CheckBadge {
if (ready === null) return { label: '—', variant: 'outline' };
if (ready.status === 'ready') {
return {
label: 'Готов',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
return { label: ready.status || 'Не готов', variant: 'destructive' };
}
@@ -0,0 +1,79 @@
import { z } from 'zod';
function isValidIPv4(value: string): boolean {
const parts = value.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return false;
if (part.length > 1 && part.startsWith('0')) return false;
const n = Number(part);
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
}
return true;
}
function isValidIPv6(value: string): boolean {
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
if ((value.match(/::/g) ?? []).length > 1) return false;
const hasCompression = value.includes('::');
const [leftRaw, rightRaw = ''] = value.split('::');
const left = leftRaw === '' ? [] : leftRaw.split(':');
const right = rightRaw === '' ? [] : rightRaw.split(':');
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
let segments = [...left, ...right];
let ipv4TailSegments = 0;
const lastSegment = segments.at(-1);
if (lastSegment && lastSegment.includes('.')) {
if (!isValidIPv4(lastSegment)) return false;
segments = segments.slice(0, -1);
ipv4TailSegments = 2;
}
for (const segment of segments) {
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
}
const totalSegments = segments.length + ipv4TailSegments;
if (hasCompression) return totalSegments < 8;
return totalSegments === 8;
}
const optionalIPv4 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
message: `Введите корректный IPv4 адрес (${label})`
});
const optionalIPv6 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
message: `Введите корректный IPv6 адрес (${label})`
});
export const settingsKnownSchema = z.object({
bird_router_id: optionalIPv4('router id'),
bird_local_ipv4: optionalIPv4('local IPv4'),
bird_local_ipv6: optionalIPv6('local IPv6'),
bird_local_asn: z.string().refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
message: 'ASN должен быть целым числом больше 0'
}),
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6'),
revision_retention_minutes: z.string().refine(
(v) => {
const s = v.trim();
if (s === '') return true;
const ttl = Number(s);
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
},
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
)
});
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
});
+38 -2
View File
@@ -1,5 +1,3 @@
/** Русские подписи для enum из API (задачи, модули, логи refresh). */
export function jobStatusRu(status: string): string {
switch (status) {
case 'queued':
@@ -17,6 +15,18 @@ export function jobStatusRu(status: string): string {
}
}
/** Variant Badge для статуса задачи (shadcn). */
export function jobStatusBadgeVariant(
status: string
): 'default' | 'secondary' | 'outline' | 'destructive' {
const lower = status.toLowerCase();
if (lower === 'succeeded') return 'default';
if (lower === 'running' || lower === 'queued') return 'secondary';
if (lower === 'failed' || lower === 'error' || lower === 'canceled' || lower === 'cancelled')
return 'destructive';
return 'outline';
}
/** Подпись типа задачи для фильтров (значения API те же). */
export function jobKindFilterRu(kind: string): string {
switch (kind) {
@@ -33,6 +43,32 @@ export function jobKindFilterRu(kind: string): string {
}
}
/** Включён / выключен модуль (поле enabled). */
export function moduleEnabledRu(enabled: boolean): string {
return enabled ? 'Вкл' : 'Выкл';
}
/** Variant Badge для enabled модуля. */
export function moduleEnabledBadgeVariant(
enabled: boolean
): 'default' | 'secondary' | 'outline' | 'destructive' {
return enabled ? 'default' : 'secondary';
}
/** Политика DoH-резолвера для модулей DOMAINS. */
export function dohPolicyRu(policy: string | null | undefined): string {
switch (policy) {
case 'primary_only':
return 'Только первый';
case 'failover':
return 'Резервирование';
case 'union':
return 'Объединение';
default:
return 'Только первый';
}
}
export function moduleTypeRu(type: string): string {
switch (type) {
case 'AS_PREFIXES':
@@ -7,6 +7,7 @@
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
import AppNavLinks from './app-nav-links.svelte';
import AppVersion from './app-version.svelte';
import ThemeMenu from './theme-menu.svelte';
let {
@@ -54,4 +55,5 @@
</div>
<Separator />
<AppNavLinks {collapsed} />
<AppVersion />
</aside>
@@ -0,0 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import { formatVersionSidebarLabel, type VersionInfo } from '$lib/api/version-info.js';
let label = $state<string | null>(null);
onMount(() => {
void (async () => {
try {
const info = await apiJSON<VersionInfo>('/v1/version');
label = formatVersionSidebarLabel(info);
} catch {
label = null;
}
})();
});
</script>
{#if label}
<p class="truncate px-3 pb-3 text-xs text-sidebar-foreground/45" title={label}>{label}</p>
{/if}
+1 -4
View File
@@ -7,8 +7,6 @@ import Gauge from '@lucide/svelte/icons/gauge';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Network from '@lucide/svelte/icons/network';
import Settings from '@lucide/svelte/icons/settings';
import Zap from '@lucide/svelte/icons/zap';
export type NavItem = {
href: string;
label: string;
@@ -20,8 +18,7 @@ export const mainNav: NavItem[] = [
{ href: '/modules', label: 'Модули', icon: Boxes },
{ href: '/directories', label: 'Справочники', icon: BookOpen },
{ href: '/network', label: 'Сеть', icon: Network },
{ href: '/revisions', label: 'Ревизии', icon: Activity },
{ href: '/operations', label: 'Операции', icon: Zap },
{ href: '/operations', label: 'Ревизии', icon: Activity },
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
];
@@ -15,7 +15,12 @@
const open = $derived(!!state?.open);
function onOpenChange(v: boolean) {
if (!v) closeConfirm();
if (!v && state && !state.loading) closeConfirm();
}
async function handleConfirm() {
if (!state || state.loading) return;
await state.onConfirm();
}
</script>
@@ -37,7 +42,10 @@
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
: ''}
disabled={state.loading}
onclick={state.onConfirm}
onclick={(e) => {
e.preventDefault();
void handleConfirm();
}}
>
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
</AlertDialogAction>
@@ -21,8 +21,9 @@ export function confirm(options: ConfirmOptions) {
open: true,
loading: false,
onConfirm: async () => {
if (!confirmState.current) return;
confirmState.current = { ...confirmState.current, loading: true };
const current = confirmState.current;
if (!current || current.loading) return;
confirmState.current = { ...current, loading: true };
try {
await options.onConfirm();
resolve(true);
@@ -0,0 +1,112 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
export type KpiAccent = {
border: string;
bg: string;
iconBg: string;
iconText: string;
};
export type KpiCardItem = {
id: string;
label: string;
value: string;
description: string;
icon: Component;
accent: KpiAccent;
badge: string;
badgeClass?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
valueClass?: string;
error?: string | null;
href?: string;
};
type Props = {
cards: KpiCardItem[];
loading?: boolean;
skeletonCount?: number;
class?: string;
};
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each cards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm',
card.href ? 'transition-colors hover:border-primary/35' : '',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
{#if card.href}
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={card.href}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else}
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{/if}
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
+244 -124
View File
@@ -1,25 +1,37 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiFetch, apiPageAll } from '$lib/api/client.js';
import { resolve } from '$app/paths';
import { apiJSON, apiFetch } from '$lib/api/client.js';
import type {
ModuleRow,
ModulesResponse,
RevisionRow,
RevisionsResponse,
PeerRow,
PeersResponse,
SpeakerRow,
SpeakersResponse,
JobRow,
JobsResponse
} from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/components/ui/card/index.js';
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notifyApiError } from '$lib/ui/app/toast.js';
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
import OverviewRecentRevisionsCard from '$lib/components/overview/OverviewRecentRevisionsCard.svelte';
import { cn } from '$lib/utils.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolve = (path: string) => path as any;
import CheckCircle from '@lucide/svelte/icons/check-circle';
import XCircle from '@lucide/svelte/icons/x-circle';
import Boxes from '@lucide/svelte/icons/boxes';
@@ -27,51 +39,34 @@
import Radio from '@lucide/svelte/icons/radio';
import Activity from '@lucide/svelte/icons/activity';
import Clock from '@lucide/svelte/icons/clock';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import Info from '@lucide/svelte/icons/info';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Plus from '@lucide/svelte/icons/plus';
import Tags from '@lucide/svelte/icons/tags';
import Share2 from '@lucide/svelte/icons/share-2';
import Play from '@lucide/svelte/icons/play';
import Gauge from '@lucide/svelte/icons/gauge';
let healthy = $state<boolean | null>(null);
let modules = $state(0);
let revisions = $state(0);
let peers = $state(0);
let speakers = $state(0);
let moduleItems = $state<ModuleRow[]>([]);
let modulesHasMore = $state(false);
let revisionItems = $state<RevisionRow[]>([]);
let revisionsHasMore = $state(false);
let peerItems = $state<PeerRow[]>([]);
let peersHasMore = $state(false);
let speakerItems = $state<SpeakerRow[]>([]);
let speakersHasMore = $state(false);
let jobItems = $state<JobRow[]>([]);
let recentJobs = $state<JobRow[]>([]);
let recentRevisions = $state<RevisionRow[]>([]);
let runningJobs = $state(0);
let loading = $state(true);
let initialLoading = $state(true);
let refreshing = $state(false);
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
async function countAll(path: string): Promise<number> {
const items = await apiPageAll<unknown>(path, 500);
return items.length;
}
onMount(async () => {
loading = true;
try {
const h = await apiFetch('/v1/health');
healthy = h.ok;
} catch {
healthy = false;
}
try {
const [mCount, rCount, pCount, sCount, j] = await Promise.all([
countAll('/v1/modules'),
countAll('/v1/revisions'),
countAll('/v1/peers'),
countAll('/v1/speakers'),
apiJSON<JobsResponse>('/v1/jobs?limit=100')
]);
modules = mCount;
revisions = rCount;
peers = pCount;
speakers = sCount;
runningJobs = (j.items ?? []).filter(
(i) => i.status === 'running' || i.status === 'queued'
).length;
} catch {
/* ignore */
}
loading = false;
});
const moduleNameById = $derived(new Map(moduleItems.map((m) => [m.id, m.name])));
const statAccents = [
{
@@ -106,123 +101,248 @@
}
] as const;
const stats = $derived([
function countBadge(count: number, hasMore: boolean, suffix: string) {
if (hasMore) return '200+';
return suffix;
}
const kpiCards = $derived.by(() => [
{
id: 'modules',
label: 'Модули',
value: modules,
href: '/modules',
value: initialLoading ? '—' : String(moduleItems.length),
href: '/modules' as const,
icon: Boxes,
description: 'AS, CDN, домены, IP',
accent: statAccents[0]
accent: statAccents[0],
badge: countBadge(moduleItems.length, modulesHasMore, 'в системе')
},
{
id: 'peers',
label: 'Пиры',
value: peers,
href: '/network',
value: initialLoading ? '—' : String(peerItems.length),
href: '/network' as const,
icon: GitBranch,
description: 'BGP-соседи',
accent: statAccents[1]
accent: statAccents[1],
badge: countBadge(peerItems.length, peersHasMore, 'peers')
},
{
id: 'speakers',
label: 'Спикеры',
value: speakers,
href: '/network',
value: initialLoading ? '—' : String(speakerItems.length),
href: '/network' as const,
icon: Radio,
description: 'BIRD-агенты',
accent: statAccents[2]
accent: statAccents[2],
badge: countBadge(speakerItems.length, speakersHasMore, 'agents')
},
{
id: 'revisions',
label: 'Ревизии',
value: revisions,
href: '/operations',
value: initialLoading ? '—' : String(revisionItems.length),
href: '/operations' as const,
icon: Activity,
description: 'История конфигураций',
accent: statAccents[3]
accent: statAccents[3],
badge: countBadge(revisionItems.length, revisionsHasMore, 'configs')
},
{
id: 'jobs',
label: 'Активных задач',
value: runningJobs,
href: '/operations',
value: initialLoading ? '—' : String(runningJobs),
href: '/operations?tab=jobs' as const,
icon: Clock,
description: 'Выполняются сейчас',
accent: statAccents[4]
description: 'queued и running в выборке',
accent: statAccents[4],
badge: 'running'
}
]);
function toErrorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
async function load() {
if (!initialLoading) refreshing = true;
loadError = null;
try {
try {
const h = await apiFetch('/v1/health');
healthy = h.ok;
if (!h.ok) {
loadError = `GET /v1/health: HTTP ${h.status}`;
return;
}
} catch (e) {
healthy = false;
loadError = toErrorMessage(e);
notifyApiError(e);
return;
}
const [m, p, s, r, j] = await Promise.allSettled([
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
apiJSON<PeersResponse>('/v1/peers?limit=200'),
apiJSON<SpeakersResponse>('/v1/speakers?limit=200'),
apiJSON<RevisionsResponse>('/v1/revisions?limit=200'),
apiJSON<JobsResponse>('/v1/jobs?limit=20')
]);
const firstReject = [m, p, s, r, j].find((x) => x.status === 'rejected');
if (firstReject?.status === 'rejected') {
loadError = toErrorMessage(firstReject.reason);
notifyApiError(firstReject.reason);
}
if (m.status === 'fulfilled') {
moduleItems = m.value.items ?? [];
modulesHasMore = m.value.has_more;
}
if (p.status === 'fulfilled') {
peerItems = p.value.items ?? [];
peersHasMore = p.value.has_more;
}
if (s.status === 'fulfilled') {
speakerItems = s.value.items ?? [];
speakersHasMore = s.value.has_more;
}
if (r.status === 'fulfilled') {
revisionItems = r.value.items ?? [];
revisionsHasMore = r.value.has_more;
}
if (j.status === 'fulfilled') {
jobItems = j.value.items ?? [];
recentJobs = jobItems.slice(0, 10);
recentRevisions = revisionItems.slice(0, 10);
runningJobs = jobItems.filter(
(i) => i.status === 'running' || i.status === 'queued'
).length;
}
if (!loadError) lastUpdated = new Date();
} finally {
initialLoading = false;
refreshing = false;
}
}
onMount(load);
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Обзор"
description="Состояние панели управления EvoBGP."
description={lastUpdated
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Состояние панели управления EvoBGP.'}
icon={LayoutDashboard}
iconClass="bg-primary/10 text-primary"
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Панель управления EvoBGP</AlertTitle>
<AlertDescription>
Сводка по модулям, сети и фоновым задачам. Настройка префиксов — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>, деплой и
ревизии —
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>,
здоровье системы —
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
</AlertDescription>
</Alert>
{#if healthy === null}
<Alert>
<Skeleton class="size-5 rounded-full" />
<AlertTitle>Проверка API…</AlertTitle>
<AlertDescription>Запрос к <code class="text-xs">/v1/health</code></AlertDescription>
</Alert>
{:else if healthy && !loadError}
<Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
{:else if healthy && loadError}
<Alert class="border-warning/30 bg-warning/5">
<Info class="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>
{loadError}. Для локального демо укажите Bearer-токен
<code class="text-xs">dev</code> в
<Button variant="link" class="h-auto p-0" href={resolve('/settings')}>Настройках</Button>
(нужен <code class="text-xs">EVOBGP_DEV_INSECURE=1</code> на API).
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive" class="border-destructive/30 bg-destructive/5">
<XCircle class="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
{loadError ??
'Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080), в dev — `npm run dev` с прокси Vite, в Docker — контейнер evobgp-api / evobgp-all и nginx в evobgp-web.'}
</AlertDescription>
</Alert>
{/if}
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={5}
class="sm:grid-cols-2 lg:grid-cols-3"
/>
<!-- Health -->
<Card>
<CardContent class="flex items-center gap-3 py-4">
{#if healthy === null}
<div class="size-3 animate-pulse rounded-full bg-muted"></div>
<span class="text-sm text-muted-foreground">Проверка…</span>
{:else if healthy}
<CheckCircle class="size-5 text-green-500" />
<span class="font-medium text-green-700 dark:text-green-400">API работает</span>
{:else}
<XCircle class="size-5 text-red-500" />
<span class="font-medium text-red-700 dark:text-red-400">API недоступен</span>
{/if}
</CardContent>
</Card>
<!-- Stats grid -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each stats as stat (stat.label)}
{@const Icon = stat.icon}
{@const a = stat.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{stat.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={resolve(stat.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
<CardTitle class="text-3xl font-bold tabular-nums">
{loading ? '—' : stat.value}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-xs text-muted-foreground">{stat.description}</p>
</CardContent>
</Card>
{/each}
<div class="grid gap-4 lg:grid-cols-2">
<OverviewRecentJobsCard
items={recentJobs}
{moduleNameById}
loading={refreshing}
{initialLoading}
error={loadError}
/>
<OverviewRecentRevisionsCard
items={recentRevisions}
loading={refreshing}
{initialLoading}
error={loadError}
/>
</div>
<!-- Quick links -->
<Card>
<CardHeader>
<CardHeader class="border-b py-3">
<CardTitle class="text-base">Быстрые действия</CardTitle>
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" href={resolve('/modules')}>Создать модуль</Button>
<Button variant="outline" size="sm" href={resolve('/directories')}>Добавить community</Button>
<Button variant="outline" size="sm" href={resolve('/network')}>Добавить пира</Button>
<Button variant="outline" size="sm" href={resolve('/operations')}>Деплой (Apply)</Button>
<Button variant="outline" size="sm" href={resolve('/monitoring')}>Мониторинг</Button>
<CardContent class="flex flex-wrap gap-2 p-4 pt-4">
<Button variant="outline" size="sm" href={resolve('/modules')}>
<Plus class="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" href={resolve('/directories')}>
<Tags class="size-4" />
Добавить community
</Button>
<Button variant="outline" size="sm" href={resolve('/network')}>
<Share2 class="size-4" />
Добавить пира
</Button>
<Button variant="outline" size="sm" href={resolve('/operations')}>
<Play class="size-4" />
Деплой (Apply)
</Button>
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
<Gauge class="size-4" />
Мониторинг
</Button>
</CardContent>
</Card>
</div>
+144 -373
View File
@@ -1,218 +1,180 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import { resolve } from '$app/paths';
import { apiJSON } from '$lib/api/client.js';
import type {
BgpCommunity,
BgpCommunityCreate,
CommunitiesResponse,
DohProfile,
DohProfileCreate,
DohProfilesResponse
} from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/components/ui/card/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction
} from '$lib/components/ui/alert-dialog/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notifyApiError } from '$lib/ui/app/toast.js';
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
import DirectoriesDohProfilesCard from '$lib/components/directories/DirectoriesDohProfilesCard.svelte';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import BookOpen from '@lucide/svelte/icons/book-open';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import Info from '@lucide/svelte/icons/info';
import Tags from '@lucide/svelte/icons/tags';
import Globe from '@lucide/svelte/icons/globe';
import Library from '@lucide/svelte/icons/library';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
// --- Communities ---
let communities = $state<BgpCommunity[]>([]);
let commLoading = $state(false);
let commDialog = $state(false);
let commEdit = $state<BgpCommunity | null>(null);
let commForm = $state<BgpCommunityCreate>({ community: '', title: '' });
let commSaving = $state(false);
let commDeleteTarget = $state<BgpCommunity | null>(null);
// --- DoH Profiles ---
let dohProfiles = $state<DohProfile[]>([]);
let dohLoading = $state(false);
let dohDialog = $state(false);
let dohEdit = $state<DohProfile | null>(null);
let dohForm = $state<DohProfileCreate & { timeout_ms?: number | null }>({
url: '',
timeout_ms: null,
vault_secret_ref: null
});
let dohSaving = $state(false);
let dohDeleteTarget = $state<DohProfile | null>(null);
let loading = $state(false);
let initialLoading = $state(true);
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
async function loadComm() {
commLoading = true;
try {
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
communities = r.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
commLoading = false;
const statAccents = [
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
}
] as const;
const kpiCards = $derived.by(() => [
{
id: 'communities',
label: 'Сообщества BGP',
value: initialLoading ? '—' : String(communities.length),
description: 'теги префиксов в AS- и CDN-модулях',
icon: Tags,
accent: statAccents[0],
badge: 'community'
},
{
id: 'doh',
label: 'DoH профили',
value: initialLoading ? '—' : String(dohProfiles.length),
description: 'резолвинг доменных модулей',
icon: Globe,
accent: statAccents[1],
badge: 'DNS-over-HTTPS'
},
{
id: 'shared',
label: 'Справочники',
value: 'Общие',
description: 'используются всеми модулями tenant',
icon: Library,
accent: statAccents[2],
badge: 'tenant-wide',
href: '/modules' as const
}
]);
async function loadCommunities() {
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
communities = r.items;
}
async function loadDoh() {
dohLoading = true;
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
dohProfiles = r.items;
}
async function load() {
if (!initialLoading) loading = true;
loadError = null;
try {
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
dohProfiles = r.items;
await Promise.all([loadCommunities(), loadDoh()]);
lastUpdated = new Date();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
} finally {
dohLoading = false;
loading = false;
initialLoading = false;
}
}
onMount(() => {
loadComm();
loadDoh();
});
// --- Community actions ---
function commDisplay(c: BgpCommunity | null) {
if (!c) return '';
const t = c.title?.trim();
return t || c.community;
}
function openCommCreate() {
commEdit = null;
commForm = { community: '', title: '' };
commDialog = true;
}
function openCommEdit(c: BgpCommunity) {
commEdit = c;
commForm = { community: c.community, title: c.title ?? '' };
commDialog = true;
}
async function saveComm() {
if (!commForm.community.trim()) {
toast.error('Укажите community');
return;
}
commSaving = true;
async function refreshCommunities() {
try {
const body = { ...commForm, title: commForm.title?.trim() || undefined };
if (commEdit) {
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
toast.success('Запись сообщества обновлена');
} else {
await apiMutate('/v1/communities', 'POST', body);
toast.success('Сообщество создано');
}
commDialog = false;
await loadComm();
loadError = null;
await loadCommunities();
lastUpdated = new Date();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
commSaving = false;
}
}
async function deleteComm() {
if (!commDeleteTarget) return;
try {
await apiMutate(`/v1/communities/${commDeleteTarget.id}`, 'DELETE', undefined, {
idempotent: false
});
toast.success('Удалено');
commDeleteTarget = null;
await loadComm();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
}
}
// --- DoH actions ---
function openDohCreate() {
dohEdit = null;
dohForm = { url: '', timeout_ms: null, vault_secret_ref: null };
dohDialog = true;
}
function openDohEdit(d: DohProfile) {
dohEdit = d;
dohForm = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
dohDialog = true;
}
async function saveDoh() {
if (!dohForm.url.trim()) {
toast.error('Укажите URL');
return;
}
dohSaving = true;
async function refreshDoh() {
try {
if (dohEdit) {
await apiMutate(`/v1/doh-profiles/${dohEdit.id}`, 'PATCH', dohForm);
toast.success('DoH профиль обновлён');
} else {
await apiMutate('/v1/doh-profiles', 'POST', dohForm);
toast.success('DoH профиль создан');
}
dohDialog = false;
loadError = null;
await loadDoh();
lastUpdated = new Date();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
dohSaving = false;
}
}
async function deleteDoh() {
if (!dohDeleteTarget) return;
try {
await apiMutate(`/v1/doh-profiles/${dohDeleteTarget.id}`, 'DELETE', undefined, {
idempotent: false
});
toast.success('Удалено');
dohDeleteTarget = null;
await loadDoh();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
}
}
onMount(load);
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов."
description={lastUpdated
? `Сообщества BGP и DoH-профили для резолвинга доменов. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Сообщества BGP и DoH-профили для резолвинга доменов.'}
icon={BookOpen}
iconClass="bg-chart-2/15 text-chart-2"
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>О справочниках</AlertTitle>
<AlertDescription>
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
доменных модулях для DNS-over-HTTPS резолвинга. Настройка модулей — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>.
</AlertDescription>
</Alert>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Tabs value="communities">
@@ -221,215 +183,24 @@
<TabsTrigger value="doh">DoH профили</TabsTrigger>
</TabsList>
<!-- Communities -->
<TabsContent value="communities" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">Сообщества BGP</CardTitle>
<CardDescription>Используются для тегирования префиксов</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadComm} disabled={commLoading}>
<RefreshCw class={commLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openCommCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Код сообщества</TableHead>
<TableHead>Название</TableHead>
<TableHead>ID</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each communities as c (c.id)}
<TableRow>
<TableCell class="font-mono text-sm font-medium">{c.community}</TableCell>
<TableCell>{c.title?.trim() || '—'}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground">{c.id}</TableCell>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openCommEdit(c)}
><Pencil class="size-3.5" /></Button
>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => (commDeleteTarget = c)}><Trash2 class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
{commLoading ? 'Загрузка…' : 'Нет записей. Создайте первую.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<DirectoriesCommunitiesCard
items={communities}
{loading}
{initialLoading}
error={loadError}
onRefresh={refreshCommunities}
/>
</TabsContent>
<!-- DoH Profiles -->
<TabsContent value="doh" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">DoH профили</CardTitle>
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription
>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadDoh} disabled={dohLoading}>
<RefreshCw class={dohLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openDohCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Таймаут (мс)</TableHead>
<TableHead>ID</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each dohProfiles as d (d.id)}
<TableRow>
<TableCell class="font-mono text-sm">{d.url}</TableCell>
<TableCell class="text-muted-foreground">{d.timeout_ms ?? '—'}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground">{d.id}</TableCell>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openDohEdit(d)}
><Pencil class="size-3.5" /></Button
>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => (dohDeleteTarget = d)}><Trash2 class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
{dohLoading ? 'Загрузка…' : 'Нет DoH профилей.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<DirectoriesDohProfilesCard
items={dohProfiles}
{loading}
{initialLoading}
error={loadError}
onRefresh={refreshDoh}
/>
</TabsContent>
</Tabs>
</div>
<!-- Community Dialog -->
<Dialog bind:open={commDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{commEdit ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Код сообщества" id="c-community" required>
<AppInput id="c-community" bind:value={commForm.community} placeholder="65001:120" />
</FormField>
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
<AppInput id="c-title" bind:value={commForm.title} placeholder="Название" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (commDialog = false)}>Отмена</Button>
<Button onclick={saveComm} disabled={commSaving}
>{commSaving ? 'Сохранение…' : commEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!commDeleteTarget}
onOpenChange={(v) => {
if (!v) commDeleteTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить сообщество «{commDisplay(commDeleteTarget)}»?</AlertDialogTitle>
<AlertDialogDescription
>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (commDeleteTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={deleteComm}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- DoH Dialog -->
<Dialog bind:open={dohDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{dohEdit ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="URL" id="doh-url" required>
<AppInput
id="doh-url"
bind:value={dohForm.url}
placeholder="https://dns.google/dns-query"
/>
</FormField>
<FormField label="Таймаут (мс)" id="doh-timeout">
<AppInput
id="doh-timeout"
type="number"
bind:value={dohForm.timeout_ms}
placeholder="5000"
/>
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dohDialog = false)}>Отмена</Button>
<Button onclick={saveDoh} disabled={dohSaving}
>{dohSaving ? 'Сохранение…' : dohEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!dohDeleteTarget}
onOpenChange={(v) => {
if (!v) dohDeleteTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить DoH профиль?</AlertDialogTitle>
<AlertDialogDescription>{dohDeleteTarget?.url}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (dohDeleteTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={deleteDoh}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
+130 -175
View File
@@ -1,65 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { ModuleRow, ModulesResponse, ModuleCreate } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import type { ModuleRow, ModulesResponse } from '$lib/api/types.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
formatDateTime,
moduleIntervalLabel,
moduleTypeBadgeVariant
} from '$lib/modules/display.js';
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { resolve } from '$app/paths';
import ModuleCreateDialog from '$lib/components/modules/ModuleCreateDialog.svelte';
import { cn } from '$lib/utils.js';
import Plus from '@lucide/svelte/icons/plus';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import ExternalLink from '@lucide/svelte/icons/external-link';
import Trash2 from '@lucide/svelte/icons/trash-2';
import { moduleTypeRu } from '$lib/ui-labels.js';
import Boxes from '@lucide/svelte/icons/boxes';
import Info from '@lucide/svelte/icons/info';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import CircleOff from '@lucide/svelte/icons/circle-off';
let rows = $state<ModuleRow[]>([]);
let loading = $state(false);
let initialLoading = $state(true);
let dialogOpen = $state(false);
let saving = $state(false);
let selectedModuleIds = $state(new Set<string>());
let deletingBulkModules = $state(false);
let selectedModuleIds = $state(new Set<string>());
let lastUpdated = $state<Date | null>(null);
const selectedModulesCount = $derived(selectedModuleIds.size);
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
const someModulesSelected = $derived(selectedModuleIds.size > 0 && !allModulesSelected);
const enabledCount = $derived(rows.filter((m) => m.enabled).length);
const disabledCount = $derived(rows.filter((m) => !m.enabled).length);
let form = $state<ModuleCreate>({
type: 'AS_PREFIXES',
name: '',
enabled: true,
priority: 0
});
const moduleTypes = [
{ value: 'AS_PREFIXES', label: 'AS (номера)' },
{ value: 'CDN_CIDRS', label: 'CDN CIDRs' },
{ value: 'DOMAINS', label: 'Домены' },
{ value: 'IP_RANGES', label: 'IP Ranges' }
const statAccents = [
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
}
] as const;
const kpiCards = $derived.by(() => [
{
id: 'total',
label: 'Всего модулей',
value: initialLoading ? '—' : String(rows.length),
description: 'AS, CDN, домены, IP',
icon: Boxes,
accent: statAccents[0],
badge: 'в системе'
},
{
id: 'enabled',
label: 'Включено',
value: initialLoading ? '—' : String(enabledCount),
description: 'участвуют в ревизиях',
icon: CheckCircle2,
accent: statAccents[1],
badge: 'активных'
},
{
id: 'disabled',
label: 'Выключено',
value: initialLoading ? '—' : String(disabledCount),
description: disabledCount > 0 ? 'не участвуют в сборке' : 'все модули включены',
icon: CircleOff,
accent: statAccents[2],
badge: disabledCount > 0 ? 'отключены' : 'нет отключённых'
}
]);
const moduleColumns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
@@ -82,78 +123,23 @@
] as const;
async function load() {
loading = true;
if (!initialLoading) loading = true;
try {
const m = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
rows = m.items ?? [];
const validIds = new Set(rows.map((item) => item.id));
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
lastUpdated = new Date();
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
initialLoading = false;
}
}
onMount(load);
async function create() {
if (!form.name.trim()) {
notify.error('Укажите название модуля');
return;
}
saving = true;
try {
await apiMutate('/v1/modules', 'POST', form);
notify.success('Модуль создан');
dialogOpen = false;
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function typeBadgeVariant(type: string) {
switch (type) {
case 'AS_PREFIXES':
return 'default';
case 'CDN_CIDRS':
return 'secondary';
case 'DOMAINS':
return 'outline';
case 'IP_RANGES':
return 'outline';
default:
return 'outline';
}
}
function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
const raw = moduleRow.refresh_interval_sec as unknown;
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null;
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
if (cron) return cron;
if (intervalLabel) return intervalLabel;
return '—';
}
function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
function toggleModuleSelection(id: string) {
const next = new Set(selectedModuleIds);
if (next.has(id)) next.delete(id);
@@ -202,7 +188,9 @@
<div class="flex flex-col gap-6">
<PageHeader
title="Модули префиксов"
description="Управление модулями — AS, CDN, домены, IP-диапазоны."
description={lastUpdated
? `Управление модулями — AS, CDN, домены, IP-диапазоны. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Управление модулями — AS, CDN, домены, IP-диапазоны.'}
icon={Boxes}
iconClass="bg-chart-1/15 text-chart-1"
>
@@ -218,9 +206,31 @@
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>О модулях префиксов</AlertTitle>
<AlertDescription>
Модули собирают префиксы из AS (RIPEstat), CDN URL, доменов (DoH) и статических CIDR.
Расписание и ручной refresh — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/schedule')}>Расписание</Button>.
Результаты обновления и ревизии — в
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операциях</Button>.
</AlertDescription>
</Alert>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Card>
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
<CardTitle class="text-base">Список модулей</CardTitle>
<div>
<CardTitle class="text-base">Список модулей</CardTitle>
<CardDescription>Клик по названию открывает карточку модуля и его записи.</CardDescription>
</div>
{#if selectedModulesCount > 0}
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
@@ -241,10 +251,22 @@
columns={[...moduleColumns]}
{rows}
rowKey={(m) => m.id}
{loading}
loading={initialLoading || loading}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль."
>
{#snippet toolbar()}
{#if rows.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allModulesSelected}
onCheckedChange={(v) => toggleAllModules(v === true)}
aria-label="Выбрать все модули"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: m, column })}
{#if column.id === 'select'}
<Checkbox
@@ -253,9 +275,14 @@
onCheckedChange={() => toggleModuleSelection(m.id)}
/>
{:else if column.id === 'name'}
<span class="font-medium">{m.name}</span>
<div class="flex items-center gap-2">
<span class="font-medium">{m.name}</span>
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else if column.id === 'type'}
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
{:else if column.id === 'priority'}
<span class="text-muted-foreground">{m.priority}</span>
{:else if column.id === 'interval'}
@@ -265,11 +292,9 @@
>{formatDateTime(m.last_refreshed_at)}</span
>
{:else if column.id === 'status'}
{#if m.enabled}
<Badge variant="default" class="text-xs">вкл</Badge>
{:else}
<Badge variant="secondary" class="text-xs">выкл</Badge>
{/if}
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
{moduleEnabledRu(!!m.enabled)}
</Badge>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
<ExternalLink class="size-3.5" />
@@ -281,74 +306,4 @@
</Card>
</div>
<!-- Create Dialog -->
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="m-name">Название</Label>
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
</div>
<div class="space-y-1.5">
<Label for="m-type">Тип</Label>
<Select
type="single"
value={form.type}
onValueChange={(v) => {
if (v) form.type = v as typeof form.type;
}}
>
<SelectTrigger id="m-type" class="w-full">
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
</SelectTrigger>
<SelectContent>
{#each moduleTypes as t (t.value)}
<SelectItem value={t.value}>{t.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="m-priority">Приоритет</Label>
<Input id="m-priority" type="number" bind:value={form.priority} />
</div>
<div class="space-y-1.5">
<Label for="m-interval">Интервал (сек)</Label>
<Input
id="m-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
</div>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Модуль участвует в сборке ревизий, если включён.
</p>
</div>
<Switch
id="m-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ModuleCreateDialog bind:open={dialogOpen} onClose={() => {}} onCreated={load} />
File diff suppressed because it is too large. Load diff
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more