This commit is contained in:
parent
5faadcd6eb
commit
b77b272a5e
@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@ -12,14 +13,21 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||
"git.pcux.cn/xuans/Smail/services/runtimelog"
|
||||
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func logQueryError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, logstore.ErrInvalid) || errors.Is(err, runtimelog.ErrInvalid) {
|
||||
writeConfigError(w, config.ErrInvalidAuditQuery)
|
||||
return
|
||||
}
|
||||
log.WithError(err).Warn("日志查询失败")
|
||||
response.NewErrorResponse(response.ServerError, "日志查询失败,请检查日志存储状态", "LOG_QUERY_FAILED").FPrint(w)
|
||||
}
|
||||
|
||||
// GetLogs 只读日志入口,授权在服务端检查,不依赖导航隐藏。
|
||||
func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := authorizeCapability(ctx, access.CapabilityAuditRead, "", false); err != nil {
|
||||
writeConfigAccessError(w, err)
|
||||
return
|
||||
@ -42,7 +50,7 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request)
|
||||
if values.Get("kind") == "runtime" {
|
||||
writer := runtimelog.Current()
|
||||
if writer == nil {
|
||||
logQueryError(w, runtimelog.ErrInvalid)
|
||||
logQueryError(w, logstore.ErrUnavailable)
|
||||
return
|
||||
}
|
||||
if values.Get("file") == "" {
|
||||
|
||||
@ -94,7 +94,7 @@ func TestAdminPluginRestartScope(t *testing.T) {
|
||||
|
||||
func TestManagementEndpointCatalog(t *testing.T) {
|
||||
endpoints := ManagementEndpoints()
|
||||
if len(endpoints) != 34 {
|
||||
if len(endpoints) != 35 {
|
||||
t.Fatalf("unexpected endpoint count: %d", len(endpoints))
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
@ -112,6 +112,10 @@ func TestManagementEndpointCatalog(t *testing.T) {
|
||||
if !ok || endpoint.Capability != CapabilityPluginWriteOwn {
|
||||
t.Fatal("plugin settings endpoint was not matched")
|
||||
}
|
||||
endpoint, ok = LookupEndpoint("GET", "/api/v1/logs")
|
||||
if !ok || endpoint.Capability != CapabilityAuditRead {
|
||||
t.Fatal("log endpoint was not matched")
|
||||
}
|
||||
endpoint, ok = LookupEndpoint("POST", "/api/v1/config/smtp/validate")
|
||||
if !ok || endpoint.Capability != CapabilityConfigWriteGlobal {
|
||||
t.Fatal("configuration validation endpoint was not matched")
|
||||
|
||||
@ -27,6 +27,7 @@ var managementEndpoints = []Endpoint{
|
||||
{Method: "GET", Path: "/api/v1/config/:section", Capability: CapabilityConfigRead},
|
||||
{Method: "PUT", Path: "/api/v1/config/:section", Capability: CapabilityConfigWriteGlobal},
|
||||
{Method: "GET", Path: "/api/v1/audits/config", Capability: CapabilityAuditRead},
|
||||
{Method: "GET", Path: "/api/v1/logs", Capability: CapabilityAuditRead},
|
||||
{Method: "GET", Path: "/api/v1/audits/config/:id", Capability: CapabilityAuditRead},
|
||||
{Method: "POST", Path: "/api/v1/audits/config", Capability: CapabilityAuditExport},
|
||||
{Method: "POST", Path: "/api/v1/audits/config/export", Capability: CapabilityAuditExport},
|
||||
|
||||
@ -81,19 +81,24 @@ func (w *Writer) Maintain(ctx context.Context, now time.Time) error {
|
||||
if now.IsZero() {
|
||||
return ErrInvalid
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.archiveMu.Lock()
|
||||
defer w.archiveMu.Unlock()
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
// 关闭当前文件后维护,避免 Windows 无法替换句柄;下一条日志会重新打开。
|
||||
if w.file != nil {
|
||||
cutoff := logpolicy.Cutoff(now)
|
||||
w.mu.Lock()
|
||||
openDay, _ := time.Parse("2006-01-02", w.day)
|
||||
// 仅关闭本轮要处理的历史句柄,当天写入不与历史文件扫描共用长时间锁。
|
||||
if w.file != nil && (!openDay.After(cutoff) || !openDay.Add(24*time.Hour).After(now.Add(-logpolicy.CompressionAge))) {
|
||||
err := errors.Join(w.file.Sync(), w.file.Close())
|
||||
w.file = nil
|
||||
if err != nil {
|
||||
w.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
directory, err := w.root.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
@ -103,7 +108,6 @@ func (w *Writer) Maintain(ctx context.Context, now time.Time) error {
|
||||
if err != nil || closeErr != nil {
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
cutoff := logpolicy.Cutoff(now)
|
||||
var failures []error
|
||||
for _, entry := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
||||
@ -22,8 +22,8 @@ type Page struct {
|
||||
}
|
||||
|
||||
func (w *Writer) Files() ([]File, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.archiveMu.Lock()
|
||||
defer w.archiveMu.Unlock()
|
||||
if w.closed {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
@ -57,8 +57,8 @@ func (w *Writer) Files() ([]File, error) {
|
||||
|
||||
// Read 仅允许固定名称的受管日志,不接受任意路径;压缩内容在服务端流式读取。
|
||||
func (w *Writer) Read(ctx context.Context, name string, offset, limit int) (Page, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.archiveMu.Lock()
|
||||
defer w.archiveMu.Unlock()
|
||||
if _, ok := fileDay(name); !ok || offset < 0 || offset > 1000000 || limit < 1 || limit > 500 || w.closed {
|
||||
return Page{}, ErrInvalid
|
||||
}
|
||||
|
||||
@ -24,14 +24,15 @@ var ErrInvalid = errors.New("运行日志文件或查询参数无效")
|
||||
var active atomic.Pointer[Writer]
|
||||
|
||||
type Writer struct {
|
||||
mu sync.Mutex
|
||||
root *os.Root
|
||||
file *os.File
|
||||
day string
|
||||
now func() time.Time
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
closed bool
|
||||
archiveMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
root *os.Root
|
||||
file *os.File
|
||||
day string
|
||||
now func() time.Time
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func Current() *Writer { return active.Load() }
|
||||
@ -95,11 +96,6 @@ func (w *Writer) Fire(entry *log.Entry) error {
|
||||
}
|
||||
|
||||
func (w *Writer) WriteAt(at time.Time, message string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
if at.IsZero() {
|
||||
return ErrInvalid
|
||||
}
|
||||
@ -111,6 +107,18 @@ func (w *Writer) WriteAt(at time.Time, message string) error {
|
||||
message = string([]rune(message)[:min(len([]rune(message)), (maxLineBytes-128)/4)]) + "…"
|
||||
}
|
||||
day := at.UTC().Format("2006-01-02")
|
||||
w.mu.Lock()
|
||||
// 当天句柄已打开时直接追加,不等待历史归档的读取或压缩。
|
||||
if !w.closed && (w.file == nil || w.day != day) {
|
||||
w.mu.Unlock()
|
||||
w.archiveMu.Lock()
|
||||
defer w.archiveMu.Unlock()
|
||||
w.mu.Lock()
|
||||
}
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
if w.file == nil || w.day != day {
|
||||
if w.file != nil {
|
||||
if err := errors.Join(w.file.Sync(), w.file.Close()); err != nil {
|
||||
@ -158,6 +166,8 @@ func (w *Writer) Close() error {
|
||||
w.cancel()
|
||||
<-w.done
|
||||
}
|
||||
w.archiveMu.Lock()
|
||||
defer w.archiveMu.Unlock()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$runtime = Join-Path $root 'yunx-test'
|
||||
$logs = Join-Path $runtime 'log'
|
||||
$logs = Join-Path $root 'test-log\service-launch'
|
||||
# 控制台与热更新输出属于模拟部署测试记录,业务运行日志由程序按天管理。
|
||||
New-Item -ItemType Directory -Path $logs -Force | Out-Null
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||
$previousRoot = $env:SMAIL_ROOT
|
||||
@ -78,7 +79,7 @@ try {
|
||||
Start-Sleep -Seconds 3
|
||||
$process.Refresh()
|
||||
if ($process.HasExited) { throw "服务启动失败;标准输出:$stdout;错误日志:$stderr" }
|
||||
Write-Output "$Service 已启动,进程编号:$($process.Id);日志目录:yunx-test/log"
|
||||
Write-Output "$Service 已启动,进程编号:$($process.Id);运行日志:yunx-test/log;启动诊断:test-log/service-launch"
|
||||
Write-Output "运行文件:$binary;标准输出:$stdout;错误日志:$stderr"
|
||||
} finally {
|
||||
$env:SMAIL_ROOT = $previousRoot
|
||||
|
||||
@ -23,12 +23,14 @@ foreach ($name in @('SMAIL_ROOT', 'SMAIL_HTTP_PORT_OVERRIDE', 'SMAIL_HTTP_TLS_MO
|
||||
try {
|
||||
$env:SMAIL_ROOT = $runtime
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
||||
$logs = Join-Path $root 'test-log\service-launch'
|
||||
New-Item -ItemType Directory -Path $logs -Force | Out-Null
|
||||
$process = Start-Process -FilePath $binary -WorkingDirectory $runtime -WindowStyle Hidden -PassThru `
|
||||
-RedirectStandardOutput (Join-Path $runtime "log\production-$stamp.stdout.log") `
|
||||
-RedirectStandardError (Join-Path $runtime "log\production-$stamp.stderr.log")
|
||||
-RedirectStandardOutput (Join-Path $logs "production-$stamp.stdout.log") `
|
||||
-RedirectStandardError (Join-Path $logs "production-$stamp.stderr.log")
|
||||
Start-Sleep -Seconds 3
|
||||
$process.Refresh()
|
||||
if ($process.HasExited) { throw '生产模拟启动失败,请检查 yunx-test/log;不会自动豁免管理员恢复通道保护' }
|
||||
if ($process.HasExited) { throw '生产模拟启动失败,请检查 test-log/service-launch;不会自动豁免管理员恢复通道保护' }
|
||||
Write-Output "生产模拟已启动:$($process.Id),使用嵌入前端和 yunx-test 配置,管理员恢复通道保持保护。"
|
||||
} finally {
|
||||
foreach ($name in $previous.Keys) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') }
|
||||
|
||||
@ -55,8 +55,12 @@ export function usePage() {
|
||||
{ key: 'users', label: lang.user_management, hint: '账号与权限管理', icon: User, component: UserManagement, capability: 'user.manage' },
|
||||
{ key: 'plugins', label: lang.plugin_settings, hint: '扩展功能管理', icon: Grid, component: PluginSettings, capability: 'plugin.read' },
|
||||
{ key: 'logs', label: '日志', hint: '操作、收发信与运行记录', icon: Document, component: LogSettings, capability: 'audit.read' },
|
||||
].filter(item => globalStatus.hasCapability(item.capability) ||
|
||||
(item.key === 'plugins' && globalStatus.hasCapability('config.read'))))
|
||||
].filter(item => {
|
||||
// 全局日志包含其他账号与运行信息,不能把本人审计能力当作全局权限。
|
||||
if (item.key === 'logs' && globalStatus.role !== 'super_admin') return false
|
||||
return globalStatus.hasCapability(item.capability) ||
|
||||
(item.key === 'plugins' && globalStatus.hasCapability('config.read'))
|
||||
}))
|
||||
|
||||
const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab))
|
||||
const settingsPages = new Map()
|
||||
|
||||
@ -17,6 +17,7 @@ export function useLogs() {
|
||||
const pageSize = 50
|
||||
let requestId = 0
|
||||
let aborter
|
||||
const previousOffsets = []
|
||||
|
||||
const categories = [
|
||||
{ label: '配置审计', value: 'config' },
|
||||
@ -28,13 +29,13 @@ export function useLogs() {
|
||||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '结果未知', accepted: '已接收' }[value] || value)
|
||||
const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value)
|
||||
const displayActor = item => item.payload?.actorName || item.actorId || '系统'
|
||||
const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统')
|
||||
const details = computed(() => detail.value?.payload || {})
|
||||
const changes = computed(() => details.value.changes || [])
|
||||
|
||||
function requireData(result) {
|
||||
if (result?.errorNo) throw new Error(result.errorMsg || '日志查询失败')
|
||||
return result?.data || {}
|
||||
if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败')
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@ -53,6 +54,9 @@ export function useLogs() {
|
||||
files.value = listing.files || []
|
||||
if (!files.value.some(file => file.name === selectedFile.value)) {
|
||||
selectedFile.value = files.value[0]?.name || ''
|
||||
offset.value = 0
|
||||
params.offset = 0
|
||||
previousOffsets.length = 0
|
||||
}
|
||||
if (!selectedFile.value) {
|
||||
lines.value = []
|
||||
@ -86,15 +90,19 @@ export function useLogs() {
|
||||
|
||||
function search() {
|
||||
offset.value = 0
|
||||
load()
|
||||
previousOffsets.length = 0
|
||||
return load()
|
||||
}
|
||||
function next() {
|
||||
if (loading.value || !hasMore.value) return
|
||||
previousOffsets.push(offset.value)
|
||||
offset.value += isRuntime.value ? lines.value.length : pageSize
|
||||
load()
|
||||
return load()
|
||||
}
|
||||
function previous() {
|
||||
offset.value = Math.max(0, offset.value - pageSize)
|
||||
load()
|
||||
if (loading.value || offset.value === 0) return
|
||||
offset.value = previousOffsets.pop() ?? 0
|
||||
return load()
|
||||
}
|
||||
watch(category, () => {
|
||||
detail.value = null
|
||||
|
||||
77
test/backend/logs_api_test.go
Normal file
77
test/backend/logs_api_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/db/migration"
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
|
||||
database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "logs.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
// 与正式 SQLite 连接池一致,避免测试自行引入多写连接锁冲突。
|
||||
database.SetMaxOpenConns(1)
|
||||
items := migration.DefaultMigrations()
|
||||
for _, statement := range items[len(items)-1].StatementsFor(migration.DialectSQLite) {
|
||||
if _, err := database.Exec(statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
store, err := logstore.New(database, migration.DialectSQLite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := logstore.Start(store)
|
||||
defer runtime.Stop()
|
||||
if err := store.Write(context.Background(), logstore.Entry{Kind: "operation", CreatedAt: time.Now(), ActorID: "42", Action: "probe", Result: "success", Payload: []byte(`{}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(role access.Role, query string) response.Response {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest("GET", "/api/v1/logs?"+query, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
GetLogs(&appcontext.Context{Role: role, UserID: 42}, recorder, request)
|
||||
if recorder.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("sensitive log response can be cached")
|
||||
}
|
||||
var result response.Response
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} {
|
||||
for _, kind := range []string{"operation", "config", "mail", "runtime"} {
|
||||
if got := call(role, "kind="+kind+"&actorId=42"); got.ErrorNo != response.NoAccessPrivileges {
|
||||
t.Fatalf("unscoped role %s accessed %s: %d", role, kind, got.ErrorNo)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} {
|
||||
if got := call(access.RoleSuperAdmin, query); got.ErrorNo != response.ParamsError {
|
||||
t.Fatalf("invalid query accepted: %s => %d", query, got.ErrorNo)
|
||||
}
|
||||
}
|
||||
result := call(access.RoleSuperAdmin, "kind=operation&actorId=42&limit=1")
|
||||
if result.ErrorNo != 0 {
|
||||
t.Fatalf("administrator query failed: %d", result.ErrorNo)
|
||||
}
|
||||
data := result.Data.(map[string]any)
|
||||
if len(data["items"].([]any)) != 1 || data["retentionDays"] != float64(30) {
|
||||
t.Fatal("log query lost records or retention policy")
|
||||
}
|
||||
}
|
||||
@ -93,3 +93,29 @@ func TestRuntimeLogCorruptionAndUnknownFilesArePreserved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLogCurrentWritesDoNotWaitForArchiveRead(t *testing.T) {
|
||||
w, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Close()
|
||||
now := time.Now()
|
||||
if err := w.WriteAt(now, "first"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.archiveMu.Lock()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.WriteAt(now, "second") }()
|
||||
select {
|
||||
case err := <-done:
|
||||
w.archiveMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
w.archiveMu.Unlock()
|
||||
<-done
|
||||
t.Fatal("current log write waited for archive lock")
|
||||
}
|
||||
}
|
||||
|
||||
87
test/frontend/logs.test.mjs
Normal file
87
test/frontend/logs.test.mjs
Normal file
@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { compileFunction } from 'node:vm'
|
||||
import { createRequire } from 'node:module'
|
||||
import { computed, ref } from './vue.mjs'
|
||||
|
||||
const root = new URL('../../frontend/src/views/components/LogSettings/', import.meta.url)
|
||||
const read = name => readFileSync(new URL(name, root), 'utf8')
|
||||
const create = compileFunction(
|
||||
read('LogSettings.js').replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useLogs()',
|
||||
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http']
|
||||
)
|
||||
|
||||
function fixture(get) {
|
||||
let dispose
|
||||
const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get })
|
||||
return { page, dispose: () => dispose() }
|
||||
}
|
||||
|
||||
test('日志页面模板和样式能编译,详情不使用原始 HTML 渲染', () => {
|
||||
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
|
||||
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
|
||||
const { descriptor, errors } = parse(read('LogSettings.vue'))
|
||||
assert.deepEqual(errors, [])
|
||||
assert.doesNotThrow(() => compileScript(descriptor, { id: 'logs', inlineTemplate: true }))
|
||||
assert.deepEqual(compileStyle({ source: read('LogSettings.css'), id: 'logs', scoped: true }).errors, [])
|
||||
assert.doesNotMatch(read('LogSettings.vue'), /v-html/)
|
||||
})
|
||||
|
||||
test('日志查询传递时间与操作者筛选,并将业务失败显示为错误', async () => {
|
||||
const requests = []
|
||||
const { page } = fixture(async (_, options) => {
|
||||
requests.push(options.params)
|
||||
return requests.length === 1
|
||||
? { errorNo: 0, data: { items: [{ id: 'safe' }], hasMore: false } }
|
||||
: { errorNo: 500, errorMsg: '日志暂不可用' }
|
||||
})
|
||||
page.actorId.value = ' 42 '
|
||||
page.dateRange.value = ['2026-09-01T00:00:00Z', '2026-09-08T00:00:00Z']
|
||||
await page.search()
|
||||
assert.equal(requests[0].actorId, '42')
|
||||
assert.equal(requests[0].from, '2026-09-01T00:00:00.000Z')
|
||||
assert.equal(page.items.value.length, 1)
|
||||
await page.search()
|
||||
assert.equal(page.error.value, '日志暂不可用')
|
||||
assert.equal(page.items.value.length, 0)
|
||||
assert.equal(page.displayActor({ actorId: '0' }), '系统')
|
||||
})
|
||||
|
||||
test('压缩日志短页正确返回上一页,文件过期后重置偏移', async () => {
|
||||
let filename = 'runtime-2026-08-15.log.gz'
|
||||
const offsets = []
|
||||
const { page } = fixture(async (_, { params }) => {
|
||||
if (!params.file) return { errorNo: 0, data: { files: [{ name: filename, compressed: true }] } }
|
||||
offsets.push(params.offset)
|
||||
return { errorNo: 0, data: { lines: Array(params.offset === 0 ? 8 : 12).fill('日志'), hasMore: true } }
|
||||
})
|
||||
page.category.value = 'runtime'
|
||||
await page.search()
|
||||
await page.next()
|
||||
await page.next()
|
||||
await page.previous()
|
||||
assert.deepEqual(offsets, [0, 8, 20, 8])
|
||||
filename = 'runtime-2026-08-16.log.gz'
|
||||
await page.next()
|
||||
assert.equal(page.offset.value, 0)
|
||||
assert.equal(offsets.at(-1), 0)
|
||||
})
|
||||
|
||||
test('慢响应及卸载后的响应不得覆盖最新日志', async () => {
|
||||
let complete
|
||||
let count = 0
|
||||
const { page, dispose } = fixture(async () => {
|
||||
if (++count === 1) return new Promise(resolve => { complete = resolve })
|
||||
return { errorNo: 0, data: { items: [{ id: 'new' }] } }
|
||||
})
|
||||
const first = page.search()
|
||||
await page.search()
|
||||
complete({ errorNo: 0, data: { items: [{ id: 'old' }] } })
|
||||
await first
|
||||
assert.equal(page.items.value[0].id, 'new')
|
||||
const last = page.search()
|
||||
dispose()
|
||||
await last
|
||||
assert.equal(page.items.value[0].id, 'new')
|
||||
})
|
||||
@ -48,7 +48,7 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async
|
||||
assert.match(content, /\$workingDirectory = \$runtime/)
|
||||
for (const name of ['spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe']) assert.ok(content.includes(name))
|
||||
assert.match(content, /Join-Path \$runtime 'smail\.exe'/)
|
||||
assert.match(content, /Join-Path \$runtime 'log'/)
|
||||
assert.match(content, /Join-Path \$root 'test-log\\service-launch'/)
|
||||
assert.match(content, /-WindowStyle Hidden/)
|
||||
assert.doesNotMatch(content, /SkipBuild/)
|
||||
assert.match(content, /--dev-disable-admin-reset/)
|
||||
|
||||
@ -35,7 +35,7 @@ test('二级菜单只发送可见且尚未选中的选项,不提前修改父
|
||||
test('一级菜单保留能力过滤和当前分类标题联动', () => {
|
||||
const source = read('HomeHeader/HomeHeader.js')
|
||||
const names = [
|
||||
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid',
|
||||
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', 'Document',
|
||||
'SettingsPageStatus', 'BrandLogo'
|
||||
]
|
||||
const createPage = compileFunction(
|
||||
@ -64,6 +64,13 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
|
||||
assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), false)
|
||||
capabilities.add('plugin.read')
|
||||
assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), true)
|
||||
capabilities.add('audit.read')
|
||||
for (const role of ['user', 'admin']) {
|
||||
store.role = role
|
||||
assert.equal(page.settingsMenu.value.some(item => item.key === 'logs'), false)
|
||||
}
|
||||
store.role = 'super_admin'
|
||||
assert.equal(page.settingsMenu.value.at(-1).key, 'logs')
|
||||
})
|
||||
|
||||
test('三级菜单只发送当前分组的可见子项,选中状态仍由父页面确认', () => {
|
||||
|
||||
@ -49,6 +49,7 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
|
||||
const args = extra[0] === '--' ? extra.slice(1) : extra
|
||||
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
|
||||
const replacements = {
|
||||
'backend/controllers/logs_api_test.go': 'test/backend/logs_api_test.go',
|
||||
'backend/services/logstore/log_store_test.go': 'test/backend/log_store_test.go',
|
||||
'backend/services/runtimelog/runtime_logs_test.go': 'test/backend/runtime_logs_test.go',
|
||||
'backend/db/migration/log_migration_test.go': 'test/backend/log_migration_test.go',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user