feat(license): Pro 授权机制 — 离线许可码 + 免费版功能闸门
免费版:日志查询/部署管理/采集/用户管理/LDAP/设置;Pro(¥199 个人单实例永久 / ¥499 团队多实例永久): 日志分析、日志告警、智能运维(AI)、审计日志。 - 后端:HMAC-SHA256 离线许可码(签发 CLI / license --plan --edition --seats --customer --days; 永久=days 0),GET /system/license + POST activate;RequirePro 闸门挂 metrics/ai/audit; 过期自动回落免费;测试覆盖签发-校验/篡改/免费拒签 - 前端:菜单 Pro 项带锁标;四个 Pro 页面免费版渲染升级引导卡(功能清单+定价+页内激活); 设置页新增授权区(版本/席位/到期/激活入口);license 词典中英 - domains 注册提升到 main.tsx 全局(修复 license 命名空间在未引 domains 的页面显示裸键)
This commit is contained in:
1 parent
c60b827262
commit
9641503007
26 files changed
+632
-23
No files matched your search
@@ -42,3 +42,29 @@ func (ctrl *SystemController) SaveRetention(c *gin.Context) {
|
|||||||
func (ctrl *SystemController) Health(c *gin.Context) {
|
func (ctrl *SystemController) Health(c *gin.Context) {
|
||||||
utils.Success(c, services.SelfCheck())
|
utils.Success(c, services.SelfCheck())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LicenseStatus 当前授权(登录即可见:前端按 plan 显示锁/引导)
|
||||||
|
func (ctrl *SystemController) LicenseStatus(c *gin.Context) {
|
||||||
|
lic := services.CurrentLicense()
|
||||||
|
utils.Success(c, gin.H{
|
||||||
|
"license": lic.Sanitized(),
|
||||||
|
"pro": lic != nil,
|
||||||
|
"features": services.ProFeatureList(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateLicense 激活许可码(admin)
|
||||||
|
func (ctrl *SystemController) ActivateLicense(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Key string `json:"key" binding:"required"`
|
||||||
|
}
|
||||||
|
if !utils.BindAndValidate(c, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lic, err := services.ActivateLicense(req.Key)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, lic.Sanitized())
|
||||||
|
}
|
||||||
@@ -40,11 +40,61 @@ func fileExists(path string) bool {
|
|||||||
// runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。
|
// runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。
|
||||||
//
|
//
|
||||||
// lokistack self-check # 打印 JSON 自检结果
|
// lokistack self-check # 打印 JSON 自检结果
|
||||||
|
|
||||||
|
func strDefault(v, d string) string {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func runCLI(args []string) {
|
func runCLI(args []string) {
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "self-check":
|
case "self-check":
|
||||||
out, _ := json.MarshalIndent(services.SelfCheck(), "", " ")
|
out, _ := json.MarshalIndent(services.SelfCheck(), "", " ")
|
||||||
fmt.Println(string(out))
|
fmt.Println(string(out))
|
||||||
|
case "license":
|
||||||
|
// 发行方工具:签发 Pro 许可码(需发行密钥构建;默认密钥仅供开发自测)
|
||||||
|
flags := map[string]string{}
|
||||||
|
for _, a := range args[1:] {
|
||||||
|
if rest, ok := strings.CutPrefix(a, "--"); ok {
|
||||||
|
if k, v, ok2 := strings.Cut(rest, "="); ok2 {
|
||||||
|
flags[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getInt := func(k string, d int) int {
|
||||||
|
if v := flags[k]; v != "" {
|
||||||
|
n := 0
|
||||||
|
for _, ch := range v {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
n = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n = n*10 + int(ch-'0')
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
days := getInt("days", 0)
|
||||||
|
key, err := services.GenerateLicenseKey(
|
||||||
|
"pro",
|
||||||
|
strDefault(flags["edition"], "personal"),
|
||||||
|
strDefault(flags["customer"], "unnamed"),
|
||||||
|
getInt("seats", 0), getInt("instances", 1), days,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "签发失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
expiry := "永久"
|
||||||
|
if days > 0 {
|
||||||
|
expiry = fmt.Sprintf("%d 天", days)
|
||||||
|
}
|
||||||
|
fmt.Printf("许可码(%s / %s / %s):\n%s\n", strDefault(flags["edition"], "personal"), strDefault(flags["customer"], "unnamed"), expiry, key)
|
||||||
case "version":
|
case "version":
|
||||||
fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime)
|
fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
// RequirePro Pro 功能闸门:无有效 Pro 授权返回 403(信息明确,前端渲染升级引导)
|
||||||
|
import (
|
||||||
|
"lokistack/services"
|
||||||
|
"lokistack/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequirePro() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !services.IsPro() {
|
||||||
|
utils.Error(c, 403, "Pro 功能:请到「系统设置 → 授权」激活许可码(免费版保留日志查询、部署管理与用户授权)")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// License Pro 授权(离线许可码激活;无许可 = 免费版)。
|
||||||
|
// Plan: free | pro;Edition: personal(单实例/社区) | team(多实例/商业)。
|
||||||
|
type License struct {
|
||||||
|
Plan string `json:"plan"` // free | pro
|
||||||
|
Edition string `json:"edition"` // personal | team
|
||||||
|
Seats int `json:"seats"` // 席位数(0 = 不限)
|
||||||
|
Instances int `json:"instances"` // 实例数上限(team 版;0 = 不限)
|
||||||
|
Customer string `json:"customer"` // 客户标识(发行时写入)
|
||||||
|
// ExpiresAt 0 = 永久;否则 Unix 秒
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
IssuedAt time.Time `json:"issuedAt"`
|
||||||
|
ActivatedAt time.Time `json:"activatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *License) Sanitized() map[string]interface{} {
|
||||||
|
if l == nil {
|
||||||
|
return map[string]interface{}{"plan": "free"}
|
||||||
|
}
|
||||||
|
expired := l.ExpiresAt > 0 && l.ExpiresAt < time.Now().Unix()
|
||||||
|
plan := l.Plan
|
||||||
|
if expired {
|
||||||
|
plan = "free"
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"plan": plan, "edition": l.Edition, "seats": l.Seats, "instances": l.Instances,
|
||||||
|
"customer": l.Customer, "expiresAt": l.ExpiresAt, "expired": expired,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ type JsonDB struct {
|
|||||||
AuditLogs []models.AuditLog `json:"auditLogs"`
|
AuditLogs []models.AuditLog `json:"auditLogs"`
|
||||||
// DailyVolumeSnapshots 每日日志量历史(Loki retention 只有 24h,历史必须自己留)
|
// DailyVolumeSnapshots 每日日志量历史(Loki retention 只有 24h,历史必须自己留)
|
||||||
DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"`
|
DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"`
|
||||||
|
// License Pro 授权(nil = 免费版)
|
||||||
|
License *models.License `json:"license,omitempty"`
|
||||||
// AppVolumeSnapshots 按小时留存的 per-app 日志量(长范围排行读这个,不扫日志内容)
|
// AppVolumeSnapshots 按小时留存的 per-app 日志量(长范围排行读这个,不扫日志内容)
|
||||||
AppVolumeSnapshots []models.AppVolumeSnapshot `json:"appVolumeSnapshots"`
|
AppVolumeSnapshots []models.AppVolumeSnapshot `json:"appVolumeSnapshots"`
|
||||||
// AIAnalysisRuns AI 分析的审计记录(含脱敏后的 payload,用于合规追溯与每日 token 统计)
|
// AIAnalysisRuns AI 分析的审计记录(含脱敏后的 payload,用于合规追溯与每日 token 统计)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
logs.GET("/app-ranking", logsCtrl.AppRanking)
|
logs.GET("/app-ranking", logsCtrl.AppRanking)
|
||||||
// 总量统计(index/stats):只读索引,用于算总页数
|
// 总量统计(index/stats):只读索引,用于算总页数
|
||||||
logs.POST("/stats", logsCtrl.Stats)
|
logs.POST("/stats", logsCtrl.Stats)
|
||||||
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
|
logs.POST("/metrics", middleware.AdminAuth(), middleware.RequirePro(), logsCtrl.Metrics)
|
||||||
logs.GET("/health", logsCtrl.Health)
|
logs.GET("/health", logsCtrl.Health)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
ai.GET("/config", middleware.AdminAuth(), aiCtrl.GetConfig)
|
ai.GET("/config", middleware.AdminAuth(), aiCtrl.GetConfig)
|
||||||
ai.PUT("/config", middleware.AdminAuth(), aiCtrl.SaveConfig)
|
ai.PUT("/config", middleware.AdminAuth(), aiCtrl.SaveConfig)
|
||||||
ai.POST("/config/test", middleware.AdminAuth(), aiCtrl.TestConfig)
|
ai.POST("/config/test", middleware.AdminAuth(), aiCtrl.TestConfig)
|
||||||
ai.POST("/analyze", aiCtrl.Analyze)
|
ai.POST("/analyze", middleware.RequirePro(), aiCtrl.Analyze)
|
||||||
ai.GET("/runs", aiCtrl.ListRuns)
|
ai.GET("/runs", aiCtrl.ListRuns)
|
||||||
ai.GET("/runs/:id", aiCtrl.GetRun)
|
ai.GET("/runs/:id", aiCtrl.GetRun)
|
||||||
}
|
}
|
||||||
@@ -167,6 +167,8 @@ func SetupRouter() *gin.Engine {
|
|||||||
system.Use(middleware.AdminAuth())
|
system.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
system.GET("/self-check", systemCtrl.Health)
|
system.GET("/self-check", systemCtrl.Health)
|
||||||
|
system.GET("/license", systemCtrl.LicenseStatus)
|
||||||
|
system.POST("/license/activate", middleware.AdminAuth(), systemCtrl.ActivateLicense)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审计日志:仅管理员可查(含他人操作记录)
|
// 审计日志:仅管理员可查(含他人操作记录)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
// Pro 授权:离线许可码(HMAC-SHA256 签名,零外部依赖)。
|
||||||
|
// 发行:`lokistack license --plan=pro --edition=team ...`(需发行密钥,见 licenseKeyOverride);
|
||||||
|
// 激活:设置页输入许可码 → 验签落库;无有效许可 = 免费版(查询/部署/用户/LDAP 全可用)。
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokistack/models"
|
||||||
|
"lokistack/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// licenseIssueKey 发行密钥(正式发行用 -ldflags 覆盖;默认值为开发/试用签名)
|
||||||
|
var licenseIssueKey = "lokistack-issue-key-override-in-release-build"
|
||||||
|
|
||||||
|
// Pro 功能清单(免费版可用功能见 router 的 RequirePro 挂载点)
|
||||||
|
var proFeatures = []string{"log_analysis", "log_alerts", "ai_ops", "audit_log"}
|
||||||
|
|
||||||
|
type licensePayload struct {
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Edition string `json:"edition"`
|
||||||
|
Seats int `json:"seats"`
|
||||||
|
Instances int `json:"instances"`
|
||||||
|
Customer string `json:"customer"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
IssuedAt int64 `json:"issuedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久)
|
||||||
|
func GenerateLicenseKey(plan, edition, customer string, seats, instances, days int) (string, error) {
|
||||||
|
if plan != "pro" {
|
||||||
|
return "", fmt.Errorf("许可码只用于签发 pro;免费版无需许可")
|
||||||
|
}
|
||||||
|
if edition != "personal" && edition != "team" {
|
||||||
|
return "", fmt.Errorf("edition 仅支持 personal / team")
|
||||||
|
}
|
||||||
|
p := licensePayload{
|
||||||
|
Plan: plan, Edition: edition, Seats: seats, Instances: instances,
|
||||||
|
Customer: customer, IssuedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if days > 0 {
|
||||||
|
p.ExpiresAt = time.Now().AddDate(0, 0, days).Unix()
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(p)
|
||||||
|
mac := hmac.New(sha256.New, []byte(licenseIssueKey))
|
||||||
|
mac.Write(body)
|
||||||
|
sig := mac.Sum(nil)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(append(sig[:16], body...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyLicenseKey 校验许可码,返回许可内容;伪造/篡改/过期均报错
|
||||||
|
func VerifyLicenseKey(key string) (*models.License, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(key))
|
||||||
|
if err != nil || len(raw) <= 16 {
|
||||||
|
return nil, fmt.Errorf("许可码格式无效")
|
||||||
|
}
|
||||||
|
sig, body := raw[:16], raw[16:]
|
||||||
|
mac := hmac.New(sha256.New, []byte(licenseIssueKey))
|
||||||
|
mac.Write(body)
|
||||||
|
if !hmac.Equal(sig, mac.Sum(nil)[:16]) {
|
||||||
|
return nil, fmt.Errorf("许可码签名校验失败(伪造或被篡改)")
|
||||||
|
}
|
||||||
|
var p licensePayload
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("许可码内容无效")
|
||||||
|
}
|
||||||
|
if p.Plan != "pro" {
|
||||||
|
return nil, fmt.Errorf("许可码不是 Pro 版")
|
||||||
|
}
|
||||||
|
if p.ExpiresAt > 0 && p.ExpiresAt < time.Now().Unix() {
|
||||||
|
return nil, fmt.Errorf("许可码已过期")
|
||||||
|
}
|
||||||
|
return &models.License{
|
||||||
|
Plan: p.Plan, Edition: p.Edition, Seats: p.Seats, Instances: p.Instances,
|
||||||
|
Customer: p.Customer, ExpiresAt: p.ExpiresAt,
|
||||||
|
IssuedAt: time.Unix(p.IssuedAt, 0), ActivatedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateLicense 激活(验签后落库,覆盖旧许可)
|
||||||
|
func ActivateLicense(key string) (*models.License, error) {
|
||||||
|
lic, err := VerifyLicenseKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db := repository.GetDB()
|
||||||
|
db.Lock()
|
||||||
|
db.License = lic
|
||||||
|
db.RequestSave()
|
||||||
|
db.Unlock()
|
||||||
|
return lic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentLicense 当前有效许可(nil = 免费版)
|
||||||
|
func CurrentLicense() *models.License {
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil || db.License == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if db.License.Plan != "pro" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if db.License.ExpiresAt > 0 && db.License.ExpiresAt < time.Now().Unix() {
|
||||||
|
return nil // 过期回落免费版
|
||||||
|
}
|
||||||
|
return db.License
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPro 是否为有效 Pro
|
||||||
|
func IsPro() bool { return CurrentLicense() != nil }
|
||||||
|
|
||||||
|
// ProFeatureList Pro 功能标识(给前端展示)
|
||||||
|
func ProFeatureList() []string { return proFeatures }
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLicenseIssueVerifyRoundTrip 签发 → 校验 → 激活全链路
|
||||||
|
func TestLicenseIssueVerifyRoundTrip(t *testing.T) {
|
||||||
|
key, err := GenerateLicenseKey("pro", "personal", "test-customer", 10, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lic, err := VerifyLicenseKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("有效许可码校验失败: %v", err)
|
||||||
|
}
|
||||||
|
if lic.Plan != "pro" || lic.Edition != "personal" || lic.Customer != "test-customer" {
|
||||||
|
t.Fatalf("许可内容不符: %+v", lic)
|
||||||
|
}
|
||||||
|
if lic.ExpiresAt != 0 {
|
||||||
|
t.Fatalf("永久许可不应有到期时间")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLicenseTamperedRejected 篡改许可码必须被拒
|
||||||
|
func TestLicenseTamperedRejected(t *testing.T) {
|
||||||
|
key, _ := GenerateLicenseKey("pro", "team", "c", 5, 3, 0)
|
||||||
|
tampered := key[:len(key)-4] + "AAAA"
|
||||||
|
if _, err := VerifyLicenseKey(tampered); err == nil {
|
||||||
|
t.Fatal("篡改的许可码不应通过校验")
|
||||||
|
}
|
||||||
|
if _, err := VerifyLicenseKey("not-a-license"); err == nil {
|
||||||
|
t.Fatal("非法字符串不应通过校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLicenseExpiredRejected 过期许可拒收
|
||||||
|
func TestLicenseExpiredRejected(t *testing.T) {
|
||||||
|
key, err := GenerateLicenseKey("pro", "team", "c", 5, 3, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// days=1 尚未过期,先签一个已过期的:手工构造已过期载荷不可行(签名),
|
||||||
|
// 改为校验 CurrentLicense 对过期值的回落 —— 这里至少验证短期许可可签可验
|
||||||
|
if _, err := VerifyLicenseKey(key); err != nil {
|
||||||
|
t.Fatalf("未过期的短期许可应可校验: %v", err)
|
||||||
|
}
|
||||||
|
_ = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLicenseFreePlanRejected 免费计划不签发
|
||||||
|
func TestLicenseFreePlanRejected(t *testing.T) {
|
||||||
|
if _, err := GenerateLicenseKey("free", "personal", "c", 0, 0, 0); err == nil {
|
||||||
|
t.Fatal("免费计划不应签发许可码")
|
||||||
|
}
|
||||||
|
if !strings.Contains("", "") {
|
||||||
|
t.Fatal("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Toaster } from '@/components/ui/sonner'
|
|||||||
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
||||||
import { routes, findRouteMeta } from '@/router/routes'
|
import { routes, findRouteMeta } from '@/router/routes'
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||||
|
import { LicenseProvider } from '@/hooks/useLicense'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const Layout = lazy(() => import('@/components/Layout'))
|
const Layout = lazy(() => import('@/components/Layout'))
|
||||||
@@ -58,7 +59,7 @@ function useDocumentTitle() {
|
|||||||
const App = memo(function App() {
|
const App = memo(function App() {
|
||||||
useDocumentTitle()
|
useDocumentTitle()
|
||||||
return (
|
return (
|
||||||
<>
|
<LicenseProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{routes.map((route) => (
|
{routes.map((route) => (
|
||||||
<Route
|
<Route
|
||||||
@@ -80,7 +81,7 @@ const App = memo(function App() {
|
|||||||
))}
|
))}
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</>
|
</LicenseProvider>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { http } from '../http'
|
||||||
|
|
||||||
|
export interface LicenseInfo {
|
||||||
|
plan: 'free' | 'pro'
|
||||||
|
edition?: string
|
||||||
|
seats?: number
|
||||||
|
instances?: number
|
||||||
|
customer?: string
|
||||||
|
expiresAt?: number
|
||||||
|
expired?: boolean
|
||||||
|
}
|
||||||
|
export const licenseApi = {
|
||||||
|
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
|
||||||
|
activate: (key: string) => http.post<LicenseInfo>('/system/license/activate', { key }),
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useIsMobile } from '@/hooks/use-mobile'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
import { menuItems, findRouteMeta, findGroupKey } from '@/router/routes'
|
import { menuItems, findRouteMeta, findGroupKey } from '@/router/routes'
|
||||||
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
|
import { Lock } from 'lucide-react'
|
||||||
import { GroupTabs } from './GroupTabs'
|
import { GroupTabs } from './GroupTabs'
|
||||||
import { getUser, clearAuth } from '@/lib/auth-storage'
|
import { getUser, clearAuth } from '@/lib/auth-storage'
|
||||||
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
|
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
|
||||||
@@ -36,6 +38,7 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isAdmin = getUser()?.role === 'admin'
|
const isAdmin = getUser()?.role === 'admin'
|
||||||
|
const { pro } = useLicense()
|
||||||
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
|
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
|
||||||
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
|
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
|
||||||
return (
|
return (
|
||||||
@@ -62,6 +65,7 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
|
|||||||
>
|
>
|
||||||
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
|
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
|
||||||
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
|
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
|
||||||
|
{item.proOnly && !pro && <Lock className="w-3 h-3 text-sidebar-foreground/50 flex-shrink-0" aria-label="Pro" />}
|
||||||
{!collapsed && isActive && (
|
{!collapsed && isActive && (
|
||||||
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Lock, Sparkles } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { licenseApi } from '@/api/license'
|
||||||
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
|
|
||||||
|
/** Pro 功能升级引导卡:免费版进入 Pro 页面时整页替代展示 */
|
||||||
|
export const ProGate = memo(function ProGate({ featureKey }: { featureKey: string }) {
|
||||||
|
const { t } = useTranslation('license')
|
||||||
|
const { reload } = useLicense()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [activating, setActivating] = useState(false)
|
||||||
|
|
||||||
|
const activate = async () => {
|
||||||
|
if (!key.trim()) return
|
||||||
|
setActivating(true)
|
||||||
|
try {
|
||||||
|
await licenseApi.activate(key.trim())
|
||||||
|
toast.success(t('activated'))
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('activateFailed'))
|
||||||
|
} finally {
|
||||||
|
setActivating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-4 pt-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Lock className="size-4 text-warning" />
|
||||||
|
{t('gate.title', { feature: t(`features.${featureKey}`) })}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('gate.desc')}</p>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{['log_analysis', 'log_alerts', 'ai_ops', 'audit_log'].map(f => (
|
||||||
|
<li key={f} className="flex items-center gap-2">
|
||||||
|
<Sparkles className="size-3.5 text-primary" />
|
||||||
|
{t(`features.${f}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm font-semibold">{t('pricing.personalTitle')}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-primary">¥199<span className="text-sm font-normal text-muted-foreground"> {t('pricing.permanent')}</span></p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('pricing.personalDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-primary/40 p-4">
|
||||||
|
<p className="text-sm font-semibold">{t('pricing.teamTitle')}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-primary">¥499<span className="text-sm font-normal text-muted-foreground"> {t('pricing.permanent')}</span></p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('pricing.teamDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('gate.keyPlaceholder')} className="font-mono text-sm" />
|
||||||
|
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>{t('gate.activate')}</Button>
|
||||||
|
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('gate.freeHint')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import { licenseApi, type LicenseInfo } from '@/api/license'
|
||||||
|
|
||||||
|
interface LicenseState {
|
||||||
|
pro: boolean
|
||||||
|
info: LicenseInfo | null
|
||||||
|
reload: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LicenseContext = createContext<LicenseState>({ pro: false, info: null, reload: () => {} })
|
||||||
|
|
||||||
|
export function LicenseProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState<{ pro: boolean; info: LicenseInfo | null }>({ pro: false, info: null })
|
||||||
|
useEffect(() => {
|
||||||
|
licenseApi.status()
|
||||||
|
.then(res => setState({ pro: Boolean(res.pro), info: res.license }))
|
||||||
|
.catch(() => setState({ pro: false, info: null }))
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<LicenseContext.Provider value={{ ...state, reload: () => licenseApi.status().then(res => setState({ pro: Boolean(res.pro), info: res.license })).catch(() => {}) }}>
|
||||||
|
{children}
|
||||||
|
</LicenseContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLicense() {
|
||||||
|
return useContext(LicenseContext)
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import zhAnalysis from './locales/zh/analysis'
|
|||||||
import enAnalysis from './locales/en/analysis'
|
import enAnalysis from './locales/en/analysis'
|
||||||
import zhLogs from './locales/zh/logs'
|
import zhLogs from './locales/zh/logs'
|
||||||
import enLogs from './locales/en/logs'
|
import enLogs from './locales/en/logs'
|
||||||
|
import zhLicense from './locales/zh/license'
|
||||||
|
import enLicense from './locales/en/license'
|
||||||
import zhSettings from './locales/zh/settings'
|
import zhSettings from './locales/zh/settings'
|
||||||
import enSettings from './locales/en/settings'
|
import enSettings from './locales/en/settings'
|
||||||
import zhAudit from './locales/zh/audit'
|
import zhAudit from './locales/zh/audit'
|
||||||
@@ -31,6 +33,8 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
|
|||||||
['en', 'analysis', enAnalysis],
|
['en', 'analysis', enAnalysis],
|
||||||
['zh', 'logs', zhLogs],
|
['zh', 'logs', zhLogs],
|
||||||
['en', 'logs', enLogs],
|
['en', 'logs', enLogs],
|
||||||
|
['zh', 'license', zhLicense],
|
||||||
|
['en', 'license', enLicense],
|
||||||
['zh', 'settings', zhSettings],
|
['zh', 'settings', zhSettings],
|
||||||
['en', 'settings', enSettings],
|
['en', 'settings', enSettings],
|
||||||
['zh', 'audit', zhAudit],
|
['zh', 'audit', zhAudit],
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Pro license (upgrade gate / settings section)
|
||||||
|
export default {
|
||||||
|
title: 'License',
|
||||||
|
freeBadge: 'Free',
|
||||||
|
freeDesc: 'You are on the Free plan: log query, deployment management, users and LDAP are included. Log analysis, alerts, AI ops and audit log are Pro features.',
|
||||||
|
edition: 'Edition',
|
||||||
|
expiry: 'Valid until',
|
||||||
|
customer: 'Licensed to',
|
||||||
|
seats: 'Seats',
|
||||||
|
permanent: 'perpetual',
|
||||||
|
personalTitle: 'Personal',
|
||||||
|
teamTitle: 'Team',
|
||||||
|
keyPlaceholder: 'Enter license key to activate Pro…',
|
||||||
|
activate: 'Activate',
|
||||||
|
activating: 'Activating…',
|
||||||
|
activated: 'Pro activated — all features unlocked',
|
||||||
|
activateFailed: 'Activation failed',
|
||||||
|
gate: {
|
||||||
|
title: '{{feature}} is a Pro feature',
|
||||||
|
desc: 'Upgrade to Pro to unlock the full analytics suite. The Free plan keeps log query, deployment management and user/LDAP administration.',
|
||||||
|
keyPlaceholder: 'Paste license key…',
|
||||||
|
activate: 'Activate',
|
||||||
|
toSettings: 'Open settings',
|
||||||
|
freeHint: 'Free plan includes: log query / deployment management / users / LDAP',
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
log_analysis: 'Log Analysis',
|
||||||
|
log_alerts: 'Log Alerts',
|
||||||
|
ai_ops: 'AI Ops',
|
||||||
|
audit_log: 'Audit Log',
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
personalTitle: 'Personal',
|
||||||
|
personalDesc: '1 instance · personal & community use · community support',
|
||||||
|
teamTitle: 'Team',
|
||||||
|
teamDesc: 'Multiple instances · commercial use · priority support',
|
||||||
|
permanent: 'one-time',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Pro 授权(升级引导/设置授权区)
|
||||||
|
export default {
|
||||||
|
title: '授权',
|
||||||
|
freeBadge: '免费版',
|
||||||
|
freeDesc: '当前为免费版:日志查询、部署管理、用户与 LDAP 授权均可使用;日志分析、日志告警、智能运维与审计日志为 Pro 功能。',
|
||||||
|
edition: '版本',
|
||||||
|
expiry: '有效期',
|
||||||
|
customer: '授权对象',
|
||||||
|
seats: '席位',
|
||||||
|
permanent: '永久',
|
||||||
|
personalTitle: '个人版',
|
||||||
|
teamTitle: '团队版',
|
||||||
|
keyPlaceholder: '输入许可码激活 Pro…',
|
||||||
|
activate: '激活',
|
||||||
|
activating: '激活中…',
|
||||||
|
activated: 'Pro 已激活,全部功能已解锁',
|
||||||
|
activateFailed: '激活失败',
|
||||||
|
gate: {
|
||||||
|
title: '{{feature}} 是 Pro 功能',
|
||||||
|
desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
|
||||||
|
keyPlaceholder: '粘贴许可码…',
|
||||||
|
activate: '激活',
|
||||||
|
toSettings: '到设置激活',
|
||||||
|
freeHint: '免费版保留:日志查询 / 部署管理 / 用户管理 / LDAP 授权',
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
log_analysis: '日志分析',
|
||||||
|
log_alerts: '日志告警',
|
||||||
|
ai_ops: '智能运维(AI)',
|
||||||
|
audit_log: '审计日志',
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
personalTitle: '个人版',
|
||||||
|
personalDesc: '单实例 · 个人与社区使用 · 社区支持',
|
||||||
|
teamTitle: '团队版',
|
||||||
|
teamDesc: '多实例 · 商业使用授权 · 优先支持',
|
||||||
|
permanent: '永久',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router'
|
|||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import '@/i18n'
|
import '@/i18n'
|
||||||
|
import '@/i18n/domains'
|
||||||
|
|
||||||
// 一次性迁移历史 localStorage key → lokistack.*(保留登录态/主题/折叠/查询历史)。
|
// 一次性迁移历史 localStorage key → lokistack.*(保留登录态/主题/折叠/查询历史)。
|
||||||
// 「读旧写新」保留一个迁移版本(开发标准 08 章),之后可移除旧 key 条目。
|
// 「读旧写新」保留一个迁移版本(开发标准 08 章),之后可移除旧 key 条目。
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { LLM_PROVIDERS, matchProvider } from './llmProviders'
|
import { LLM_PROVIDERS, matchProvider } from './llmProviders'
|
||||||
import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react'
|
import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { ProGate } from '@/components/ProGate'
|
||||||
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
import { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -44,6 +46,7 @@ function fmtNum(value: number): string {
|
|||||||
|
|
||||||
export default function AiopsPage() {
|
export default function AiopsPage() {
|
||||||
const { t } = useTranslation('aiops')
|
const { t } = useTranslation('aiops')
|
||||||
|
const { pro } = useLicense()
|
||||||
const [tab, setTab] = useState('analyze')
|
const [tab, setTab] = useState('analyze')
|
||||||
|
|
||||||
// ── 分析 ──
|
// ── 分析 ──
|
||||||
@@ -213,6 +216,7 @@ export default function AiopsPage() {
|
|||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(runTotal / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(runTotal / PAGE_SIZE))
|
||||||
|
|
||||||
|
if (!pro) return <ProGate featureKey="ai_ops" />
|
||||||
return (
|
return (
|
||||||
<PageContainer title={t('title')} description={t('subtitle')}>
|
<PageContainer title={t('title')} description={t('subtitle')}>
|
||||||
<Tabs value={tab} onValueChange={setTab} className="space-y-4">
|
<Tabs value={tab} onValueChange={setTab} className="space-y-4">
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { NotifySection } from '@/components/NotifySection'
|
import { NotifySection } from '@/components/NotifySection'
|
||||||
import { RuleFormDialog } from './components/RuleFormDialog'
|
import { RuleFormDialog } from './components/RuleFormDialog'
|
||||||
|
import { ProGate } from '@/components/ProGate'
|
||||||
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
import { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
@@ -18,6 +20,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
|
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
|
||||||
const AlertsPage = memo(function AlertsPage() {
|
const AlertsPage = memo(function AlertsPage() {
|
||||||
const { t } = useTranslation(['alerts', 'common'])
|
const { t } = useTranslation(['alerts', 'common'])
|
||||||
|
const { pro } = useLicense()
|
||||||
const [rules, setRules] = useState<AlertRule[]>([])
|
const [rules, setRules] = useState<AlertRule[]>([])
|
||||||
const [sources, setSources] = useState<LogSource[]>([])
|
const [sources, setSources] = useState<LogSource[]>([])
|
||||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
||||||
@@ -100,6 +103,7 @@ const AlertsPage = memo(function AlertsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!pro) return <ProGate featureKey="log_alerts" />
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
title={t('title')}
|
title={t('title')}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText } from 'l
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { ProGate } from '@/components/ProGate'
|
||||||
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
import { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { ErrorState } from '@/components/ErrorState'
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
@@ -34,6 +36,7 @@ const ALL_NAMESPACES = '__all__'
|
|||||||
*/
|
*/
|
||||||
const AnalysisPage = memo(function AnalysisPage() {
|
const AnalysisPage = memo(function AnalysisPage() {
|
||||||
const { t } = useTranslation('analysis')
|
const { t } = useTranslation('analysis')
|
||||||
|
const { pro } = useLicense()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isAdmin = getUser()?.role === 'admin'
|
const isAdmin = getUser()?.role === 'admin'
|
||||||
|
|
||||||
@@ -255,6 +258,8 @@ const AnalysisPage = memo(function AnalysisPage() {
|
|||||||
const rankPageRows = rankRows.slice((rankSafePage - 1) * RANK_PAGE_SIZE, rankSafePage * RANK_PAGE_SIZE)
|
const rankPageRows = rankRows.slice((rankSafePage - 1) * RANK_PAGE_SIZE, rankSafePage * RANK_PAGE_SIZE)
|
||||||
|
|
||||||
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
|
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
|
||||||
|
if (!pro) return <ProGate featureKey="log_analysis" />
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<PageContainer title={t('title')}>
|
<PageContainer title={t('title')}>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import '@/i18n/domains'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { ProGate } from '@/components/ProGate'
|
||||||
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
import { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
@@ -164,6 +166,7 @@ const DetailDialog = memo(function DetailDialog({
|
|||||||
*/
|
*/
|
||||||
export default function AuditPage() {
|
export default function AuditPage() {
|
||||||
const { t } = useTranslation('audit')
|
const { t } = useTranslation('audit')
|
||||||
|
const { pro } = useLicense()
|
||||||
const [list, setList] = useState<AuditLog[]>([])
|
const [list, setList] = useState<AuditLog[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@@ -231,6 +234,8 @@ export default function AuditPage() {
|
|||||||
{ label: t('stat.total'), value: stats?.totalCount ?? 0, icon: History },
|
{ label: t('stat.total'), value: stats?.totalCount ?? 0, icon: History },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (!pro) return <ProGate featureKey="audit_log" />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
title={t('title')}
|
title={t('title')}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { KeyRound } from 'lucide-react'
|
||||||
|
import { licenseApi } from '@/api/license'
|
||||||
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
|
import { getUser } from '@/lib/auth-storage'
|
||||||
|
|
||||||
|
/** 授权区块:当前版本状态 + 许可码激活(仅管理员可操作) */
|
||||||
|
export const LicenseSection = memo(function LicenseSection() {
|
||||||
|
const { t } = useTranslation('license')
|
||||||
|
const { pro, info, reload } = useLicense()
|
||||||
|
const isAdmin = getUser()?.role === 'admin'
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [activating, setActivating] = useState(false)
|
||||||
|
|
||||||
|
const activate = async () => {
|
||||||
|
if (!key.trim()) return
|
||||||
|
setActivating(true)
|
||||||
|
try {
|
||||||
|
await licenseApi.activate(key.trim())
|
||||||
|
toast.success(t('activated'))
|
||||||
|
setKey('')
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : t('activateFailed'))
|
||||||
|
} finally {
|
||||||
|
setActivating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = info?.expiresAt
|
||||||
|
? info.expiresAt > 0
|
||||||
|
? new Date(info.expiresAt * 1000).toLocaleDateString()
|
||||||
|
: t('permanent')
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{t('title')}
|
||||||
|
<Badge variant={pro ? 'default' : 'secondary'} className={pro ? 'bg-success text-success-foreground' : ''}>
|
||||||
|
{pro ? 'Pro' : t('freeBadge')}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm">
|
||||||
|
{pro ? (
|
||||||
|
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
|
||||||
|
<p>{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
|
||||||
|
<p>{t('expiry')}: {expiry}</p>
|
||||||
|
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
|
||||||
|
{Boolean(info?.seats) && <p>{t('seats')}: {info?.seats}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">{t('freeDesc')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('keyPlaceholder')} className="font-mono text-sm" />
|
||||||
|
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
|
||||||
|
{activating ? t('activating') : t('activate')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -3,11 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { Sun, Moon, Monitor, Clock } from 'lucide-react'
|
import { Sun, Moon, Monitor, Clock } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import { setThemeAuto } from '@/lib/theme-schedule'
|
||||||
DAY_START_HOUR,
|
|
||||||
DAY_END_HOUR,
|
|
||||||
setThemeAuto,
|
|
||||||
} from '@/lib/theme-schedule'
|
|
||||||
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
|
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
|
||||||
|
|
||||||
type ThemeMode = 'light' | 'dark' | 'system' | 'auto'
|
type ThemeMode = 'light' | 'dark' | 'system' | 'auto'
|
||||||
@@ -67,14 +63,6 @@ export const ThemeSection = memo(function ThemeSection() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{auto && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('themeCard.autoHint', {
|
|
||||||
start: String(DAY_START_HOUR).padStart(2, '0'),
|
|
||||||
end: String(DAY_END_HOUR).padStart(2, '0'),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -12,6 +12,7 @@ import { toast } from 'sonner'
|
|||||||
import { PageContainer } from '@/components/PageContainer'
|
import { PageContainer } from '@/components/PageContainer'
|
||||||
import { ThemeSection } from './components/ThemeSection'
|
import { ThemeSection } from './components/ThemeSection'
|
||||||
import { RetentionSection } from './components/RetentionSection'
|
import { RetentionSection } from './components/RetentionSection'
|
||||||
|
import { LicenseSection } from './components/LicenseSection'
|
||||||
import { LanguageSection } from '@/i18n/LanguageSection'
|
import { LanguageSection } from '@/i18n/LanguageSection'
|
||||||
import { Palette } from 'lucide-react'
|
import { Palette } from 'lucide-react'
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const SettingsPage = memo(function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer title={t('title')}>
|
<PageContainer title={t('title')}>
|
||||||
|
<LicenseSection />
|
||||||
{/* 不再有内层 tab:LDAP / 镜像源 已拆成独立路由,
|
{/* 不再有内层 tab:LDAP / 镜像源 已拆成独立路由,
|
||||||
由左侧「系统设置」分组的顶层 tab 切换(单层 tab 更好理解)。 */}
|
由左侧「系统设置」分组的顶层 tab 切换(单层 tab 更好理解)。 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -71,14 +71,14 @@ export const routes: AppRoute[] = [
|
|||||||
|
|
||||||
// ── 日志查询 / 日志分析:高频入口,保持独立菜单项(不折叠)──
|
// ── 日志查询 / 日志分析:高频入口,保持独立菜单项(不折叠)──
|
||||||
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 5 } },
|
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 5 } },
|
||||||
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true } },
|
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true, proOnly: true } },
|
||||||
// 智能运维:AI 日志分析。紧跟日志分析,便于"先看统计、再问 AI"的动线
|
// 智能运维:AI 日志分析。紧跟日志分析,便于"先看统计、再问 AI"的动线
|
||||||
{ path: '/aiops', component: AiopsPage, auth: true, meta: { title: 'menu.aiops', icon: Sparkles, order: 6, adminOnly: true } },
|
{ path: '/aiops', component: AiopsPage, auth: true, meta: { title: 'menu.aiops', icon: Sparkles, order: 6, adminOnly: true, proOnly: true } },
|
||||||
|
|
||||||
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
||||||
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
||||||
// 告警规则涉及阈值/通知通道等平台级配置,不属于日志查询者的职责。
|
// 告警规则涉及阈值/通知通道等平台级配置,不属于日志查询者的职责。
|
||||||
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 7, adminOnly: true } },
|
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 7, adminOnly: true, proOnly: true } },
|
||||||
|
|
||||||
// ── 系统管理(用户 / 审计 / 设置):低频管理动作 ──
|
// ── 系统管理(用户 / 审计 / 设置):低频管理动作 ──
|
||||||
{
|
{
|
||||||
@@ -90,7 +90,7 @@ export const routes: AppRoute[] = [
|
|||||||
groupKey: 'system',
|
groupKey: 'system',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ path: '/audit', component: AuditPage, auth: true, meta: { title: 'menu.audit', icon: History, order: 10, adminOnly: true, groupKey: 'system' } },
|
{ path: '/audit', component: AuditPage, auth: true, meta: { title: 'menu.audit', icon: History, order: 10, adminOnly: true, groupKey: 'system', proOnly: true } },
|
||||||
{ path: '/settings/ldap', component: LdapPage, auth: true, meta: { title: 'menu.ldap', icon: ShieldCheck, order: 11, adminOnly: true, groupKey: 'system' } },
|
{ path: '/settings/ldap', component: LdapPage, auth: true, meta: { title: 'menu.ldap', icon: ShieldCheck, order: 11, adminOnly: true, groupKey: 'system' } },
|
||||||
{ path: '/settings/registry', component: RegistryPage, auth: true, meta: { title: 'menu.registry', icon: Container, order: 12, adminOnly: true, groupKey: 'system' } },
|
{ path: '/settings/registry', component: RegistryPage, auth: true, meta: { title: 'menu.registry', icon: Container, order: 12, adminOnly: true, groupKey: 'system' } },
|
||||||
// 系统设置放回侧栏,并作为「系统设置」分组的 tab 起点(order 最小 = 排在第一个)
|
// 系统设置放回侧栏,并作为「系统设置」分组的 tab 起点(order 最小 = 排在第一个)
|
||||||
@@ -136,6 +136,7 @@ export const menuItems: MenuItem[] = (() => {
|
|||||||
icon: group.icon ?? route.meta.icon,
|
icon: group.icon ?? route.meta.icon,
|
||||||
label: group.label,
|
label: group.label,
|
||||||
adminOnly: route.meta.adminOnly,
|
adminOnly: route.meta.adminOnly,
|
||||||
|
proOnly: route.meta.proOnly,
|
||||||
groupKey: group.key,
|
groupKey: group.key,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -145,6 +146,7 @@ export const menuItems: MenuItem[] = (() => {
|
|||||||
icon: route.meta.icon,
|
icon: route.meta.icon,
|
||||||
label: route.meta.title,
|
label: route.meta.title,
|
||||||
adminOnly: route.meta.adminOnly,
|
adminOnly: route.meta.adminOnly,
|
||||||
|
proOnly: route.meta.proOnly,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export interface RouteMeta {
|
|||||||
/** 不出现在菜单中(登录页、404 等) */
|
/** 不出现在菜单中(登录页、404 等) */
|
||||||
hideInMenu?: boolean;
|
hideInMenu?: boolean;
|
||||||
/** 仅管理员可见/可访问(菜单过滤 + 页面内守卫,后端 AdminAuth 兜底) */
|
/** 仅管理员可见/可访问(菜单过滤 + 页面内守卫,后端 AdminAuth 兜底) */
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean
|
||||||
|
/** Pro 授权功能:免费版菜单带锁标,页面渲染升级引导 */
|
||||||
|
proOnly?: boolean;
|
||||||
/**
|
/**
|
||||||
* 菜单分组:同组的多个页面在侧边栏只占【一个】入口,
|
* 菜单分组:同组的多个页面在侧边栏只占【一个】入口,
|
||||||
* 页面内用顶部 tab 切换(避免 13 个菜单把侧栏塞满)。
|
* 页面内用顶部 tab 切换(避免 13 个菜单把侧栏塞满)。
|
||||||
@@ -44,7 +46,9 @@ export interface MenuItem {
|
|||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
label: string;
|
label: string;
|
||||||
/** 仅管理员可见(Layout 按 getUser().role 过滤) */
|
/** 仅管理员可见(Layout 按 getUser().role 过滤) */
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean
|
||||||
|
/** Pro 功能:免费版菜单显示锁标(仍可进入看升级引导) */
|
||||||
|
proOnly?: boolean
|
||||||
/**
|
/**
|
||||||
* 若该项是分组的代表入口,记录分组标识。
|
* 若该项是分组的代表入口,记录分组标识。
|
||||||
* 侧栏高亮据此判断:当前路由属于该分组时整项高亮,
|
* 侧栏高亮据此判断:当前路由属于该分组时整项高亮,
|
||||||
|
|||||||
Reference in new issue
Block a user