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:
wizardchen
2026-06-04 15:48:04 +08:00
committed by lyingbug
parent f57449419c
commit 226a37330c
12 changed files with 224 additions and 5 deletions
+2
View File
@@ -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
+1
View File
@@ -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())
+4
View File
@@ -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 {
+8
View File
@@ -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',
+8
View File
@@ -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: "마이그레이션 실패",
+8
View File
@@ -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: 'Миграция не удалась',
+8
View File
@@ -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: "迁移失败",
+71 -2
View File
@@ -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>
+54 -3
View File
@@ -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
}
+15
View File
@@ -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")
+27
View File
@@ -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)
}
+18
View File
@@ -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)
}
}