mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-29 18:49:20 +08:00
feat(system): add server startup time and uptime tracking
- Introduced `started_at` and `uptime_seconds` fields to the `SystemInfo` structure for tracking server boot time and uptime. - Updated the `GetSystemInfo` handler to populate these new fields based on server runtime. - Enhanced the frontend to display server startup time and uptime in the system information view, improving visibility for users. - Added localization support for new labels and descriptions related to server uptime and startup time in multiple languages.
This commit is contained in:
@@ -19,6 +19,8 @@ type SystemInfo struct {
|
||||
MinioEnabled bool `json:"minio_enabled,omitempty"`
|
||||
DBVersion string `json:"db_version,omitempty"`
|
||||
DBMigrationError string `json:"db_migration_error,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// ParserEngine represents a document parser engine
|
||||
|
||||
@@ -53,6 +53,7 @@ func main() {
|
||||
// Print the env banner before container build so operators see what
|
||||
// config landed even when DB / storage init fails.
|
||||
runtime.LogStartupEnv(context.Background())
|
||||
runtime.MarkServerStarted()
|
||||
|
||||
// Build dependency injection container
|
||||
c := container.BuildContainer(runtime.GetContainer())
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface SystemInfo {
|
||||
* When non-empty, the system info view should surface a troubleshooting
|
||||
* banner (see docs/migration-troubleshooting.md). */
|
||||
db_migration_error?: string
|
||||
/** Server process boot time (RFC3339, UTC). */
|
||||
started_at?: string
|
||||
/** Seconds since process start. */
|
||||
uptime_seconds?: number
|
||||
}
|
||||
|
||||
export interface PlaceholderDefinition {
|
||||
|
||||
@@ -2984,6 +2984,14 @@ export default {
|
||||
buildTimeDescription: 'Time when the system was built',
|
||||
goVersionLabel: 'Go Version',
|
||||
goVersionDescription: 'Go language version used by the backend',
|
||||
startedAtLabel: 'Service started at',
|
||||
startedAtDescription: 'When the current backend process last started',
|
||||
uptimeLabel: 'Uptime',
|
||||
uptimeDescription: 'How long the service has been running since this start',
|
||||
uptimeDays: '{n}d',
|
||||
uptimeHours: '{n}h',
|
||||
uptimeMinutes: '{n}m',
|
||||
uptimeSeconds: '{n}s',
|
||||
dbVersionLabel: 'Database Version',
|
||||
dbVersionDescription: 'Current database migration version',
|
||||
dbMigrationFailedTag: 'Migration failed',
|
||||
|
||||
@@ -2216,6 +2216,14 @@ export default {
|
||||
buildTimeDescription: "시스템이 빌드된 시간",
|
||||
goVersionLabel: "Go 버전",
|
||||
goVersionDescription: "백엔드에서 사용하는 Go 언어 버전",
|
||||
startedAtLabel: "서비스 시작 시각",
|
||||
startedAtDescription: "현재 백엔드 프로세스가 마지막으로 시작된 시각",
|
||||
uptimeLabel: "가동 시간",
|
||||
uptimeDescription: "이번 시작 이후 연속 실행된 시간",
|
||||
uptimeDays: "{n}일",
|
||||
uptimeHours: "{n}시간",
|
||||
uptimeMinutes: "{n}분",
|
||||
uptimeSeconds: "{n}초",
|
||||
dbVersionLabel: "데이터베이스 버전",
|
||||
dbVersionDescription: "현재 데이터베이스 마이그레이션 버전",
|
||||
dbMigrationFailedTag: "마이그레이션 실패",
|
||||
|
||||
@@ -1935,6 +1935,14 @@ export default {
|
||||
buildTimeDescription: 'Время, когда система была собрана',
|
||||
goVersionLabel: 'Версия Go',
|
||||
goVersionDescription: 'Версия языка Go, используемая backend',
|
||||
startedAtLabel: 'Время запуска сервиса',
|
||||
startedAtDescription: 'Момент последнего запуска текущего процесса backend',
|
||||
uptimeLabel: 'Время работы',
|
||||
uptimeDescription: 'Непрерывное время работы с момента этого запуска',
|
||||
uptimeDays: '{n} д',
|
||||
uptimeHours: '{n} ч',
|
||||
uptimeMinutes: '{n} мин',
|
||||
uptimeSeconds: '{n} с',
|
||||
dbVersionLabel: 'Версия базы данных',
|
||||
dbVersionDescription: 'Текущая версия миграции базы данных',
|
||||
dbMigrationFailedTag: 'Миграция не удалась',
|
||||
|
||||
@@ -2202,6 +2202,14 @@ export default {
|
||||
buildTimeDescription: "系统构建的时间",
|
||||
goVersionLabel: "Go 版本",
|
||||
goVersionDescription: "后端使用的 Go 语言版本",
|
||||
startedAtLabel: "服务启动时间",
|
||||
startedAtDescription: "当前后端进程最近一次启动的时刻",
|
||||
uptimeLabel: "运行时长",
|
||||
uptimeDescription: "自本次启动以来的连续运行时间",
|
||||
uptimeDays: "{n} 天",
|
||||
uptimeHours: "{n} 小时",
|
||||
uptimeMinutes: "{n} 分钟",
|
||||
uptimeSeconds: "{n} 秒",
|
||||
dbVersionLabel: "数据库版本",
|
||||
dbVersionDescription: "当前数据库迁移版本号",
|
||||
dbMigrationFailedTag: "迁移失败",
|
||||
|
||||
@@ -87,6 +87,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service started at -->
|
||||
<div v-if="systemInfo?.started_at" class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('system.startedAtLabel') }}</label>
|
||||
<p class="desc">{{ $t('system.startedAtDescription') }}</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<span class="info-value">{{ formatStartedAt(systemInfo.started_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service uptime -->
|
||||
<div v-if="displayUptimeSeconds != null" class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>{{ $t('system.uptimeLabel') }}</label>
|
||||
<p class="desc">{{ $t('system.uptimeDescription') }}</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<span class="info-value">{{ formatUptime(displayUptimeSeconds) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DB Version -->
|
||||
<div v-if="systemInfo?.db_version || systemInfo?.db_migration_error" class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -170,11 +192,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { getSystemInfo, type SystemInfo } from '@/api/system'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// Reactive state
|
||||
const systemInfo = ref<SystemInfo | null>(null)
|
||||
@@ -182,6 +204,43 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
const frontendVersion = __FRONTEND_VERSION__
|
||||
|
||||
let uptimeTicker: ReturnType<typeof setInterval> | null = null
|
||||
const uptimeTick = ref(0)
|
||||
|
||||
const displayUptimeSeconds = computed(() => {
|
||||
void uptimeTick.value
|
||||
const info = systemInfo.value
|
||||
if (info?.started_at) {
|
||||
const boot = new Date(info.started_at).getTime()
|
||||
if (!Number.isNaN(boot)) {
|
||||
return Math.floor((Date.now() - boot) / 1000)
|
||||
}
|
||||
}
|
||||
if (info?.uptime_seconds != null) return info.uptime_seconds
|
||||
return null
|
||||
})
|
||||
|
||||
function formatStartedAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString(locale.value)
|
||||
}
|
||||
|
||||
function formatUptime(totalSeconds: number): string {
|
||||
const sec = Math.max(0, Math.floor(totalSeconds))
|
||||
const days = Math.floor(sec / 86400)
|
||||
const hours = Math.floor((sec % 86400) / 3600)
|
||||
const minutes = Math.floor((sec % 3600) / 60)
|
||||
const seconds = sec % 60
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(t('system.uptimeDays', { n: days }))
|
||||
if (hours > 0 || days > 0) parts.push(t('system.uptimeHours', { n: hours }))
|
||||
if (minutes > 0 || hours > 0 || days > 0) parts.push(t('system.uptimeMinutes', { n: minutes }))
|
||||
if (parts.length === 0) return t('system.uptimeSeconds', { n: seconds })
|
||||
if (seconds > 0 && days === 0) parts.push(t('system.uptimeSeconds', { n: seconds }))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const troubleshootingDocsURL =
|
||||
'https://github.com/Tencent/WeKnora/blob/main/docs/migration-troubleshooting.md'
|
||||
|
||||
@@ -236,6 +295,16 @@ const loadInfo = async () => {
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadInfo()
|
||||
uptimeTicker = setInterval(() => {
|
||||
uptimeTick.value++
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (uptimeTicker) {
|
||||
clearInterval(uptimeTicker)
|
||||
uptimeTicker = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -169,6 +169,10 @@ func (t *GrepChunksTool) Execute(ctx context.Context, args json.RawMessage) (*ty
|
||||
}
|
||||
|
||||
sort.Slice(finalResults, func(i, j int) bool {
|
||||
// Title matches rank above everything else (see chunkWithTitle.TitleMatch).
|
||||
if finalResults[i].TitleMatch != finalResults[j].TitleMatch {
|
||||
return finalResults[i].TitleMatch
|
||||
}
|
||||
if finalResults[i].MatchedPatterns != finalResults[j].MatchedPatterns {
|
||||
return finalResults[i].MatchedPatterns > finalResults[j].MatchedPatterns
|
||||
}
|
||||
@@ -213,7 +217,13 @@ type chunkWithTitle struct {
|
||||
KnowledgeTitle string `json:"knowledge_title" gorm:"column:knowledge_title"`
|
||||
MatchScore float64 `json:"match_score" gorm:"column:match_score"`
|
||||
MatchedPatterns int `json:"matched_patterns"`
|
||||
TotalChunkCount int `json:"total_chunk_count" gorm:"column:total_chunk_count"`
|
||||
// TitleMatch is true when the query regex matches the owning knowledge's
|
||||
// TITLE (not just chunk body). A doc literally titled "图片素材" is the most
|
||||
// on-topic hit for the query "图片素材", yet its body may mention the term far
|
||||
// less than long FAQ docs that repeat it — so title hits are floated to the
|
||||
// very top of both the per-chunk and per-knowledge ordering.
|
||||
TitleMatch bool `json:"title_match"`
|
||||
TotalChunkCount int `json:"total_chunk_count" gorm:"column:total_chunk_count"`
|
||||
}
|
||||
|
||||
// regexOperatorForDialect returns the SQL operator used to apply a POSIX
|
||||
@@ -282,8 +292,12 @@ func (t *GrepChunksTool) searchChunks(
|
||||
var regexConditions []string
|
||||
var regexArgs []interface{}
|
||||
for _, q := range queries {
|
||||
regexConditions = append(regexConditions, fmt.Sprintf("chunks.content %s ?", regexOp))
|
||||
regexArgs = append(regexArgs, q)
|
||||
// Match the regex against either the chunk body OR the owning
|
||||
// knowledge's title, so a doc whose title matches (e.g. titled
|
||||
// "图片素材") surfaces even when its body rarely repeats the term.
|
||||
regexConditions = append(regexConditions,
|
||||
fmt.Sprintf("(chunks.content %s ? OR knowledges.title %s ?)", regexOp, regexOp))
|
||||
regexArgs = append(regexArgs, q, q)
|
||||
}
|
||||
query = query.Where("("+strings.Join(regexConditions, " OR ")+")", regexArgs...)
|
||||
|
||||
@@ -415,6 +429,7 @@ type knowledgeAggregation struct {
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
KnowledgeTitle string `json:"knowledge_title"`
|
||||
TitleMatch bool `json:"title_match"`
|
||||
ChunkHitCount int `json:"chunk_hit_count"`
|
||||
TotalChunkCount int `json:"total_chunk_count"`
|
||||
PatternCounts map[string]int `json:"pattern_counts"`
|
||||
@@ -465,6 +480,9 @@ func (t *GrepChunksTool) aggregateByKnowledge(
|
||||
|
||||
entry := aggregated[knowledgeID]
|
||||
entry.ChunkHitCount++
|
||||
if chunk.TitleMatch {
|
||||
entry.TitleMatch = true
|
||||
}
|
||||
|
||||
occurrences := countRegexHits(chunk.Content, compiled, queryKeys)
|
||||
for _, q := range queryKeys {
|
||||
@@ -490,6 +508,11 @@ func (t *GrepChunksTool) aggregateByKnowledge(
|
||||
}
|
||||
|
||||
sort.Slice(resultSlice, func(i, j int) bool {
|
||||
// A knowledge whose TITLE matches the query is the most on-topic hit
|
||||
// and always ranks first, regardless of body keyword frequency.
|
||||
if resultSlice[i].TitleMatch != resultSlice[j].TitleMatch {
|
||||
return resultSlice[i].TitleMatch
|
||||
}
|
||||
if resultSlice[i].DistinctPatterns != resultSlice[j].DistinctPatterns {
|
||||
return resultSlice[i].DistinctPatterns > resultSlice[j].DistinctPatterns
|
||||
}
|
||||
@@ -504,6 +527,20 @@ func (t *GrepChunksTool) aggregateByKnowledge(
|
||||
return resultSlice
|
||||
}
|
||||
|
||||
// regexMatchesAny reports whether text matches at least one of the compiled
|
||||
// patterns. Used to flag title hits without counting occurrences.
|
||||
func regexMatchesAny(text string, compiled []*regexp.Regexp) bool {
|
||||
if text == "" || len(compiled) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, re := range compiled {
|
||||
if re != nil && re.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countRegexHits returns the total number of matches per (compiled) pattern
|
||||
// within content, keyed by the original (uncompiled) pattern string.
|
||||
func countRegexHits(content string, compiled []*regexp.Regexp, patterns []string) map[string]int {
|
||||
@@ -656,6 +693,20 @@ func (t *GrepChunksTool) scoreChunks(
|
||||
for i := range results {
|
||||
scored[i] = results[i]
|
||||
score, patternCount := t.calculateMatchScore(results[i].Content, compiled)
|
||||
// Title-aware boost: when the owning knowledge's TITLE matches the
|
||||
// query, treat the chunk as highly relevant regardless of how often
|
||||
// the body repeats the term. The boost keeps such chunks alive through
|
||||
// MMR selection; TitleMatch is the primary sort key downstream so they
|
||||
// also land at the very top of the final ordering.
|
||||
if regexMatchesAny(results[i].KnowledgeTitle, compiled) {
|
||||
scored[i].TitleMatch = true
|
||||
score = math.Min(score+0.5, 1.0)
|
||||
if patternCount == 0 {
|
||||
// Title-only recall (body never matched the regex) still counts
|
||||
// as one matched pattern so it isn't sorted below true zeros.
|
||||
patternCount = 1
|
||||
}
|
||||
}
|
||||
scored[i].MatchScore = score
|
||||
scored[i].MatchedPatterns = patternCount
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/application/service"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/Tencent/WeKnora/internal/database"
|
||||
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/runtime"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
@@ -121,6 +123,10 @@ type GetSystemInfoResponse struct {
|
||||
// succeeded; non-empty values let the frontend surface a troubleshooting
|
||||
// banner instead of silently hiding the DB version row (see issue #1319).
|
||||
DBMigrationError string `json:"db_migration_error,omitempty"`
|
||||
// StartedAt is the server process boot time (RFC3339, UTC).
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
// UptimeSeconds is seconds elapsed since process start.
|
||||
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// 编译时注入的版本信息
|
||||
@@ -172,6 +178,13 @@ func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
|
||||
dbVersion = "unknown"
|
||||
}
|
||||
|
||||
var startedAt string
|
||||
var uptimeSec int64
|
||||
if boot := runtime.ServerStartedAt(); !boot.IsZero() {
|
||||
startedAt = boot.UTC().Format(time.RFC3339)
|
||||
uptimeSec = int64(runtime.ServerUptime().Seconds())
|
||||
}
|
||||
|
||||
response := GetSystemInfoResponse{
|
||||
Version: Version,
|
||||
Edition: Edition,
|
||||
@@ -184,6 +197,8 @@ func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
|
||||
MinioEnabled: minioEnabled,
|
||||
DBVersion: dbVersion,
|
||||
DBMigrationError: dbMigrationErr,
|
||||
StartedAt: startedAt,
|
||||
UptimeSeconds: uptimeSec,
|
||||
}
|
||||
|
||||
logger.Info(ctx, "System info retrieved successfully")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package runtime
|
||||
|
||||
import "time"
|
||||
|
||||
// serverStartedAt is set once at process boot (see MarkServerStarted).
|
||||
var serverStartedAt time.Time
|
||||
|
||||
// MarkServerStarted records the instant the WeKnora server process started.
|
||||
// Call once from main() before heavy init so uptime reflects the full process
|
||||
// lifetime, not merely when the HTTP listener binds.
|
||||
func MarkServerStarted() {
|
||||
serverStartedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
// ServerStartedAt returns the boot instant in UTC. Zero when MarkServerStarted
|
||||
// was not called (e.g. some tests); callers should treat zero as unknown.
|
||||
func ServerStartedAt() time.Time {
|
||||
return serverStartedAt
|
||||
}
|
||||
|
||||
// ServerUptime returns elapsed time since MarkServerStarted. Zero when unset.
|
||||
func ServerUptime() time.Duration {
|
||||
if serverStartedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(serverStartedAt)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMarkServerStartedAndUptime(t *testing.T) {
|
||||
MarkServerStarted()
|
||||
boot := ServerStartedAt()
|
||||
if boot.IsZero() {
|
||||
t.Fatal("ServerStartedAt should be set after MarkServerStarted")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if got := ServerUptime(); got <= 0 {
|
||||
t.Fatalf("ServerUptime() = %v, want > 0", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user