feat: обновить название проекта и улучшить документацию
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m37s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m37s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
- Изменено название проекта с "RouterLists" на "MikrotikManager" во всех соответствующих файлах и компонентах. - Обновлены ссылки и комментарии в документации, чтобы отразить новое название и улучшить ясность. - Внесены изменения в правила коммитов, чтобы обеспечить использование русского языка в сообщениях коммитов.
This commit is contained in:
@@ -18,7 +18,7 @@ alwaysApply: false
|
|||||||
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
||||||
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
||||||
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
||||||
- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor).
|
- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor), **описание на русском**.
|
||||||
|
|
||||||
## Формат вывода (строго)
|
## Формат вывода (строго)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ alwaysApply: true
|
|||||||
|
|
||||||
**Язык:** все ответы пользователю — **только на русском**.
|
**Язык:** все ответы пользователю — **только на русском**.
|
||||||
|
|
||||||
|
**Git / Generate Commit Message:** subject и body коммита — **на русском** (префикс `feat`/`fix`/scope — на английском). Подробности — **`release-versioning.mdc`**.
|
||||||
|
|
||||||
## 1. Документация в первую очередь
|
## 1. Документация в первую очередь
|
||||||
|
|
||||||
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
---
|
---
|
||||||
description: "Conventional Commits и semver для patch/minor релизов RouterLists"
|
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Версионирование релизов
|
# Версионирование релизов
|
||||||
|
|
||||||
|
## Generate Commit Message (критично)
|
||||||
|
|
||||||
|
Для **Generate Commit Message** в Cursor, предложения subject в чате, `git commit` и любой автогенерации сообщения коммита:
|
||||||
|
|
||||||
|
- Формат: `тип(scope): описание на русском` — префикс Conventional Commits и scope **на английском**, текст после `:` **только на русском**.
|
||||||
|
- Body и footer (если нужны) — **только на русском**.
|
||||||
|
- Не использовать английские глаголы в subject: `add`, `update`, `fix`, `implement`, `rename` и т.п.
|
||||||
|
- Примеры: `feat(releases): добавить модалку сводки релиза`; `fix(ci): исправить сборку release manifest`.
|
||||||
|
|
||||||
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
||||||
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
||||||
|
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
|
||||||
|
|
||||||
## Patch (`1.0.x`)
|
## Patch (`1.0.x`)
|
||||||
|
|
||||||
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
||||||
|
|
||||||
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов.
|
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
|
||||||
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
||||||
|
|
||||||
## Minor (`1.x.0`)
|
## Minor (`1.x.0`)
|
||||||
@@ -23,10 +33,11 @@ alwaysApply: true
|
|||||||
|
|
||||||
- `fix(servers): исправить опрос API после таймаута` — patch
|
- `fix(servers): исправить опрос API после таймаута` — patch
|
||||||
- `feat(filters): добавить синхронизацию BGP in` — minor
|
- `feat(filters): добавить синхронизацию BGP in` — minor
|
||||||
- `chore: мелкие правки` — плохо; `chore(ci): добавить job prepare-release` — хорошо
|
- `chore(ci): добавить job prepare-release` — хорошо
|
||||||
|
- `feat: add release modal` — плохо (subject не на русском)
|
||||||
|
|
||||||
## Антипаттерны
|
## Антипаттерны
|
||||||
|
|
||||||
WIP, «update», смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
WIP, «update», subject на английском, смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
||||||
|
|
||||||
Подробности — раздел CI/CD в `README.md`.
|
Подробности — раздел CI/CD в `README.md`.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# MikrotikManager — инструкции для агента
|
||||||
|
|
||||||
|
## Сообщения коммитов
|
||||||
|
|
||||||
|
При **Generate Commit Message**, предложении subject для `git commit` и любой автогенерации commit message:
|
||||||
|
|
||||||
|
- Формат: `feat(scope): описание на русском` (префикс и scope — английский Conventional Commits).
|
||||||
|
- Текст после двоеточия и body — **только на русском**.
|
||||||
|
- Плохо: `feat(ui): add release modal`. Хорошо: `feat(ui): добавить модалку сводки релиза`.
|
||||||
|
|
||||||
|
Полные правила semver и примеры — `.cursor/rules/release-versioning.mdc`.
|
||||||
@@ -215,7 +215,7 @@ npm --prefix backend run db:studio
|
|||||||
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
||||||
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
|
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
|
||||||
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
|
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
|
||||||
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc).
|
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
|
||||||
|
|
||||||
## Прод-развёртывание Docker
|
## Прод-развёртывание Docker
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ function generateCombinedRouterOSConfig(
|
|||||||
const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||||
|
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`# RouterLists — конфиг фильтрации`,
|
`# MikrotikManager — конфиг фильтрации`,
|
||||||
`# Дата : ${now}`,
|
`# Дата : ${now}`,
|
||||||
`# Серверов : ${withRules.length}`,
|
`# Серверов : ${withRules.length}`,
|
||||||
`# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`,
|
`# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`,
|
||||||
@@ -271,7 +271,7 @@ function generateCombinedRouterOSConfig(
|
|||||||
|
|
||||||
lines.push(`/routing filter rule add \\`)
|
lines.push(`/routing filter rule add \\`)
|
||||||
lines.push(` chain=bgp-in \\`)
|
lines.push(` chain=bgp-in \\`)
|
||||||
lines.push(` comment="RouterLists: ${server.name}" \\`)
|
lines.push(` comment="MikrotikManager: ${server.name}" \\`)
|
||||||
lines.push(` rule="`)
|
lines.push(` rule="`)
|
||||||
lines.push(branches)
|
lines.push(branches)
|
||||||
lines.push(` "`)
|
lines.push(` "`)
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ function NativeSelect({ value, onChange, children, className }: {
|
|||||||
|
|
||||||
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
|
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`# RouterLists Firewall Export`)
|
lines.push(`# MikrotikManager Firewall Export`)
|
||||||
if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
|
if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
|
||||||
lines.push(`# Правил: ${rules.length}`)
|
lines.push(`# Правил: ${rules.length}`)
|
||||||
lines.push("")
|
lines.push("")
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
|
|||||||
const srv = serverById[t.serverId]
|
const srv = serverById[t.serverId]
|
||||||
|
|
||||||
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
||||||
lines.push(`# Сгенерировано RouterLists · ${new Date().toLocaleDateString("ru")}`)
|
lines.push(`# Сгенерировано MikrotikManager · ${new Date().toLocaleDateString("ru")}`)
|
||||||
lines.push("")
|
lines.push("")
|
||||||
|
|
||||||
// GRE interface
|
// GRE interface
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { CommandPalette } from "@/components/command-palette"
|
import { CommandPalette } from "@/components/command-palette"
|
||||||
|
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||||
import { DataSourceProvider } from "@/lib/data-source"
|
import { DataSourceProvider } from "@/lib/data-source"
|
||||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<ReleaseNotesModal />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</EvoBGPProvider>
|
</EvoBGPProvider>
|
||||||
</DataSourceProvider>
|
</DataSourceProvider>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function ReleasesPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Текущая версия</CardTitle>
|
<CardTitle>Текущая версия</CardTitle>
|
||||||
<CardDescription>Сборка RouterLists, опубликованная через CI/CD.</CardDescription>
|
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "RouterLists",
|
title: "MikrotikManager",
|
||||||
description: "MikroTik network management platform",
|
description: "MikroTik network management platform",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const PRODUCT_NAME = "MikrotikManager"
|
||||||
|
export const LEGACY_PRODUCT_NAME = "RouterLists"
|
||||||
|
|
||||||
|
export function hasManagedCommentPrefix(comment: string): boolean {
|
||||||
|
const value = comment.trim()
|
||||||
|
return value.startsWith(`${PRODUCT_NAME}:`) || value.startsWith(`${LEGACY_PRODUCT_NAME}:`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasManagedRecursiveComment(comment: string): boolean {
|
||||||
|
const value = comment.trim()
|
||||||
|
return (
|
||||||
|
value.startsWith(`${PRODUCT_NAME}:recursive`) ||
|
||||||
|
value.startsWith(`${LEGACY_PRODUCT_NAME}:recursive`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function managedComment(label: string): string {
|
||||||
|
return `${PRODUCT_NAME}: ${label}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function managedRecursiveComment(comment?: string | null): string {
|
||||||
|
return comment ? `${PRODUCT_NAME}:recursive ${comment}` : `${PRODUCT_NAME}:recursive`
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
|||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { parseDbServerId } from "../utils/server-id.js"
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
import {
|
||||||
|
hasManagedCommentPrefix,
|
||||||
|
managedComment,
|
||||||
|
} from "../managed-markers.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
type ServerRow = typeof servers.$inferSelect
|
||||||
|
|
||||||
@@ -595,11 +599,11 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
const isInBgpIn = (r: RosFilterRule) =>
|
const isInBgpIn = (r: RosFilterRule) =>
|
||||||
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
|
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
|
||||||
const managedComment = `RouterLists: ${server.name || server.host}`
|
const managedCommentValue = managedComment(server.name || server.host)
|
||||||
|
|
||||||
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
|
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
|
||||||
const managedRule = existing.find(
|
const managedRule = existing.find(
|
||||||
r => isInBgpIn(r) && (r.comment ?? "").startsWith("RouterLists:"),
|
r => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
|
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
|
||||||
@@ -608,7 +612,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const conflictIds = existing
|
const conflictIds = existing
|
||||||
.filter(r =>
|
.filter(r =>
|
||||||
isInBgpIn(r) &&
|
isInBgpIn(r) &&
|
||||||
!(r.comment ?? "").startsWith("RouterLists:") &&
|
!hasManagedCommentPrefix(r.comment ?? "") &&
|
||||||
/bgp-communities/i.test(r.rule ?? ""),
|
/bgp-communities/i.test(r.rule ?? ""),
|
||||||
)
|
)
|
||||||
.map(r => r[".id"])
|
.map(r => r[".id"])
|
||||||
@@ -641,7 +645,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
||||||
{
|
{
|
||||||
chain: "bgp-in",
|
chain: "bgp-in",
|
||||||
comment: managedComment,
|
comment: managedCommentValue,
|
||||||
rule: ruleBody,
|
rule: ruleBody,
|
||||||
disabled: "no",
|
disabled: "no",
|
||||||
},
|
},
|
||||||
@@ -650,7 +654,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
} else {
|
} else {
|
||||||
await client.post("/routing/filter/rule/add", {
|
await client.post("/routing/filter/rule/add", {
|
||||||
chain: "bgp-in",
|
chain: "bgp-in",
|
||||||
comment: managedComment,
|
comment: managedCommentValue,
|
||||||
rule: ruleBody,
|
rule: ruleBody,
|
||||||
})
|
})
|
||||||
app.log.info({ serverId: server.id }, "bgp-in rule created")
|
app.log.info({ serverId: server.id }, "bgp-in rule created")
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { db } from "../db/index.js"
|
|||||||
import { recursiveRoutes, servers } from "../db/schema.js"
|
import { recursiveRoutes, servers } from "../db/schema.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { parseDbServerId } from "../utils/server-id.js"
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
|
import {
|
||||||
|
hasManagedRecursiveComment,
|
||||||
|
managedRecursiveComment,
|
||||||
|
} from "../managed-markers.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
type ServerRow = typeof servers.$inferSelect
|
||||||
|
|
||||||
@@ -108,9 +112,7 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
|
|||||||
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
|
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
|
||||||
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
|
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
|
||||||
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
|
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
|
||||||
comment: route.comment
|
comment: managedRecursiveComment(route.comment),
|
||||||
? `RouterLists:recursive ${route.comment}`
|
|
||||||
: "RouterLists:recursive",
|
|
||||||
disabled: route.disabled ? "true" : "false",
|
disabled: route.disabled ? "true" : "false",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +237,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
|||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
const existing = await client.get<RosRoute[]>("/ip/route")
|
const existing = await client.get<RosRoute[]>("/ip/route")
|
||||||
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:recursive"))
|
const managed = existing.filter(r => hasManagedRecursiveComment(r.comment ?? ""))
|
||||||
for (const r of managed) {
|
for (const r of managed) {
|
||||||
if (!r[".id"]) continue
|
if (!r[".id"]) continue
|
||||||
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
|
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<RouterIcon className="size-4" />
|
<RouterIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
|
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
|
||||||
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">RouterLists</span>
|
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">MikrotikManager</span>
|
||||||
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
|
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
|
||||||
|
import type { ReleaseManifest } from "@/lib/release-manifest"
|
||||||
|
import { hasSeenReleaseModal, markReleaseModalSeen } from "@/lib/release-modal-storage"
|
||||||
|
|
||||||
|
export function ReleaseNotesModal() {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const [manifest, setManifest] = React.useState<ReleaseManifest | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadManifest() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/release-manifest.json", { cache: "no-store" })
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const data = (await response.json()) as ReleaseManifest
|
||||||
|
if (cancelled || !data.commits?.length || hasSeenReleaseModal(data.tag)) return
|
||||||
|
|
||||||
|
setManifest(data)
|
||||||
|
setOpen(true)
|
||||||
|
} catch {
|
||||||
|
// Локальная сборка или недоступный manifest — модалку не показываем.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadManifest()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen)
|
||||||
|
if (!nextOpen && manifest) {
|
||||||
|
markReleaseModalSeen(manifest.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionLabel = formatAppVersionLabel(manifest?.version ?? getAppVersion())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[min(85vh,720px)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[560px]">
|
||||||
|
<DialogHeader className="gap-1 border-b px-6 py-4">
|
||||||
|
<DialogTitle>Что нового в {versionLabel}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Краткая сводка изменений текущего релиза MikrotikManager.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{manifest?.commits.length ? (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{manifest.commits.map((commit) => (
|
||||||
|
<li
|
||||||
|
key={commit.sha}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border bg-card px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="font-mono uppercase">
|
||||||
|
{commit.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{commit.shortSha}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{commit.author}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed">{commit.subject}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t bg-muted/30 px-6 py-4 sm:justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
render={<Link href="/releases" />}
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Все релизы
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleOpenChange(false)}>Понятно</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
+7
-7
@@ -758,15 +758,15 @@ export const routerCertificates: RouterCertificate[] = [
|
|||||||
id: "cert1", name: "mt-msk-core-01-server", serverId: "srv1",
|
id: "cert1", name: "mt-msk-core-01-server", serverId: "srv1",
|
||||||
commonName: "mt-msk-core-01.routerlists.io",
|
commonName: "mt-msk-core-01.routerlists.io",
|
||||||
sans: ["mt-msk-core-01.routerlists.io","10.0.0.1"],
|
sans: ["mt-msk-core-01.routerlists.io","10.0.0.1"],
|
||||||
issuedBy: "RouterLists Internal CA", validFrom: "2024-01-15",
|
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-01-15",
|
||||||
validUntil: "2026-01-15", daysLeft: 261,
|
validUntil: "2026-01-15", daysLeft: 261,
|
||||||
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
|
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cert2", name: "routerlists-internal-ca", serverId: "srv1",
|
id: "cert2", name: "routerlists-internal-ca", serverId: "srv1",
|
||||||
commonName: "RouterLists Internal CA",
|
commonName: "MikrotikManager Internal CA",
|
||||||
sans: [],
|
sans: [],
|
||||||
issuedBy: "RouterLists Internal CA (self-signed)", validFrom: "2023-06-01",
|
issuedBy: "MikrotikManager Internal CA (self-signed)", validFrom: "2023-06-01",
|
||||||
validUntil: "2033-06-01", daysLeft: 2589,
|
validUntil: "2033-06-01", daysLeft: 2589,
|
||||||
keySize: 4096, usage: ["ca","crl-sign"], trusted: true, status: "valid",
|
keySize: 4096, usage: ["ca","crl-sign"], trusted: true, status: "valid",
|
||||||
},
|
},
|
||||||
@@ -774,7 +774,7 @@ export const routerCertificates: RouterCertificate[] = [
|
|||||||
id: "cert3", name: "mt-spb-edge-01-server", serverId: "srv2",
|
id: "cert3", name: "mt-spb-edge-01-server", serverId: "srv2",
|
||||||
commonName: "mt-spb-edge-01.routerlists.io",
|
commonName: "mt-spb-edge-01.routerlists.io",
|
||||||
sans: ["mt-spb-edge-01.routerlists.io","10.0.1.1"],
|
sans: ["mt-spb-edge-01.routerlists.io","10.0.1.1"],
|
||||||
issuedBy: "RouterLists Internal CA", validFrom: "2024-01-15",
|
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-01-15",
|
||||||
validUntil: "2026-01-15", daysLeft: 261,
|
validUntil: "2026-01-15", daysLeft: 261,
|
||||||
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
|
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
|
||||||
},
|
},
|
||||||
@@ -790,7 +790,7 @@ export const routerCertificates: RouterCertificate[] = [
|
|||||||
id: "cert5", name: "wg-client-admin", serverId: "srv1",
|
id: "cert5", name: "wg-client-admin", serverId: "srv1",
|
||||||
commonName: "admin.routerlists.io",
|
commonName: "admin.routerlists.io",
|
||||||
sans: ["admin.routerlists.io"],
|
sans: ["admin.routerlists.io"],
|
||||||
issuedBy: "RouterLists Internal CA", validFrom: "2025-06-01",
|
issuedBy: "MikrotikManager Internal CA", validFrom: "2025-06-01",
|
||||||
validUntil: "2026-06-01", daysLeft: 33,
|
validUntil: "2026-06-01", daysLeft: 33,
|
||||||
keySize: 2048, usage: ["client"], trusted: true, status: "valid",
|
keySize: 2048, usage: ["client"], trusted: true, status: "valid",
|
||||||
},
|
},
|
||||||
@@ -798,7 +798,7 @@ export const routerCertificates: RouterCertificate[] = [
|
|||||||
id: "cert6", name: "mt-ams-edge-01-server", serverId: "srv4",
|
id: "cert6", name: "mt-ams-edge-01-server", serverId: "srv4",
|
||||||
commonName: "mt-ams-edge-01.routerlists.io",
|
commonName: "mt-ams-edge-01.routerlists.io",
|
||||||
sans: ["mt-ams-edge-01.routerlists.io"],
|
sans: ["mt-ams-edge-01.routerlists.io"],
|
||||||
issuedBy: "RouterLists Internal CA", validFrom: "2024-04-01",
|
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-04-01",
|
||||||
validUntil: "2026-05-15", daysLeft: 16,
|
validUntil: "2026-05-15", daysLeft: 16,
|
||||||
keySize: 2048, usage: ["server"], trusted: false, status: "valid",
|
keySize: 2048, usage: ["server"], trusted: false, status: "valid",
|
||||||
},
|
},
|
||||||
@@ -806,7 +806,7 @@ export const routerCertificates: RouterCertificate[] = [
|
|||||||
id: "cert7", name: "mt-msk-lab-01-server", serverId: "srv7",
|
id: "cert7", name: "mt-msk-lab-01-server", serverId: "srv7",
|
||||||
commonName: "mt-msk-lab-01.routerlists.io",
|
commonName: "mt-msk-lab-01.routerlists.io",
|
||||||
sans: ["mt-msk-lab-01.routerlists.io","10.0.0.7"],
|
sans: ["mt-msk-lab-01.routerlists.io","10.0.0.7"],
|
||||||
issuedBy: "RouterLists Internal CA", validFrom: "2025-09-01",
|
issuedBy: "MikrotikManager Internal CA", validFrom: "2025-09-01",
|
||||||
validUntil: "2026-09-01", daysLeft: 125,
|
validUntil: "2026-09-01", daysLeft: 125,
|
||||||
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
|
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const STORAGE_PREFIX = "routerlists:release-modal:"
|
||||||
|
|
||||||
|
export function getReleaseModalStorageKey(tag: string): string {
|
||||||
|
return `${STORAGE_PREFIX}${tag}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSeenReleaseModal(tag: string): boolean {
|
||||||
|
if (typeof window === "undefined") return true
|
||||||
|
return window.localStorage.getItem(getReleaseModalStorageKey(tag)) === "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markReleaseModalSeen(tag: string): void {
|
||||||
|
window.localStorage.setItem(getReleaseModalStorageKey(tag), "1")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user