feat(license): Pro 授权机制 — 离线许可码 + 免费版功能闸门
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run

免费版:日志查询/部署管理/采集/用户管理/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:
opsx committed 2026-10-31 23:08:06 +08:00
1 parent c60b827262
commit 9641503007
26 files changed
+632 -23

No files matched your search

+26
View File
@@ -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())
}
+50
View File
@@ -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:
+20
View File
@@ -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()
}
}
+32
View File
@@ -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,
}
}
+2
View File
@@ -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 统计)
+4 -2
View File
@@ -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)
} }
// 审计日志:仅管理员可查(含他人操作记录) // 审计日志:仅管理员可查(含他人操作记录)
+119
View File
@@ -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 }
+61
View File
@@ -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")
}
}
+3 -2
View File
@@ -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>
) )
}) })
+15
View File
@@ -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 }),
}
+4
View File
@@ -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" />
)} )}
+75
View File
@@ -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>
)
})
+28
View File
@@ -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)
}
+4
View File
@@ -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],
+39
View File
@@ -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',
},
}
+39
View File
@@ -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: '永久',
},
}
+1
View File
@@ -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 条目。
+4
View File
@@ -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">
+4
View File
@@ -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')}
+5
View File
@@ -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
View File
@@ -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>
) )
}) })
+2
View File
@@ -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">
+6 -4
View File
@@ -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;
+6 -2
View File
@@ -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
/** /**
* 若该项是分组的代表入口,记录分组标识。 * 若该项是分组的代表入口,记录分组标识。
* 侧栏高亮据此判断:当前路由属于该分组时整项高亮, * 侧栏高亮据此判断:当前路由属于该分组时整项高亮,